feat(finance): overhaul completo do financeiro (Import, Filtros, UI)

- Melhora Importação: ignora linhas vazias/inválidas automaticamente.
- Filtros Server-Side: busca em todas as páginas (FOT, Nome, etc.).
- Colunas Novas: adiciona Curso, Instituição, Ano e Empresa na tabela.
- UI/UX: Corrige ordenação (vazios no fim) e adiciona scrollbar no topo.
This commit is contained in:
NANDO9322 2026-02-02 19:16:37 -03:00
parent 542c8d4388
commit a51401d9ba
7 changed files with 978 additions and 44 deletions

View file

@ -103,7 +103,7 @@ func main() {
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg))
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
financeHandler := finance.NewHandler(finance.NewService(queries))
financeHandler := finance.NewHandler(finance.NewService(queries, profissionaisService))
r := gin.Default()
@ -259,6 +259,7 @@ func main() {
financeGroup := api.Group("/finance")
{
financeGroup.POST("", financeHandler.Create)
financeGroup.POST("/import", financeHandler.Import)
financeGroup.GET("", financeHandler.List)
financeGroup.GET("/autofill", financeHandler.AutoFill)
financeGroup.GET("/fot-events", financeHandler.GetFotEvents)

View file

@ -11,13 +11,57 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const countTransactions = `-- name: CountTransactions :one
SELECT COUNT(*) FROM financial_transactions
`
func (q *Queries) CountTransactions(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, countTransactions)
var count int64
err := row.Scan(&count)
return count, err
}
const countTransactionsFiltered = `-- name: CountTransactionsFiltered :one
SELECT COUNT(*)
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
WHERE
($1::text = '' OR CAST(f.fot AS TEXT) ILIKE '%' || $1 || '%') AND
($2::text = '' OR CAST(t.data_cobranca AS TEXT) ILIKE '%' || $2 || '%') AND
($3::text = '' OR t.tipo_evento ILIKE '%' || $3 || '%') AND
($4::text = '' OR t.tipo_servico ILIKE '%' || $4 || '%') AND
($5::text = '' OR t.professional_name ILIKE '%' || $5 || '%')
`
type CountTransactionsFilteredParams struct {
Fot string `json:"fot"`
Data string `json:"data"`
Evento string `json:"evento"`
Servico string `json:"servico"`
Nome string `json:"nome"`
}
func (q *Queries) CountTransactionsFiltered(ctx context.Context, arg CountTransactionsFilteredParams) (int64, error) {
row := q.db.QueryRow(ctx, countTransactionsFiltered,
arg.Fot,
arg.Data,
arg.Evento,
arg.Servico,
arg.Nome,
)
var count int64
err := row.Scan(&count)
return count, err
}
const createTransaction = `-- name: CreateTransaction :one
INSERT INTO financial_transactions (
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra,
total_pagar, data_pagamento, pgto_ok
total_pagar, data_pagamento, pgto_ok, profissional_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING id, fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name, whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra, total_pagar, data_pagamento, pgto_ok, criado_em, atualizado_em, profissional_id
`
@ -36,6 +80,7 @@ type CreateTransactionParams struct {
TotalPagar pgtype.Numeric `json:"total_pagar"`
DataPagamento pgtype.Date `json:"data_pagamento"`
PgtoOk pgtype.Bool `json:"pgto_ok"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
}
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (FinancialTransaction, error) {
@ -54,6 +99,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
arg.TotalPagar,
arg.DataPagamento,
arg.PgtoOk,
arg.ProfissionalID,
)
var i FinancialTransaction
err := row.Scan(
@ -122,7 +168,7 @@ const listTransactions = `-- name: ListTransactions :many
SELECT t.id, t.fot_id, t.data_cobranca, t.tipo_evento, t.tipo_servico, t.professional_name, t.whatsapp, t.cpf, t.tabela_free, t.valor_free, t.valor_extra, t.descricao_extra, t.total_pagar, t.data_pagamento, t.pgto_ok, t.criado_em, t.atualizado_em, t.profissional_id, f.fot as fot_numero
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
ORDER BY t.data_cobranca DESC
ORDER BY t.data_cobranca DESC NULLS LAST
`
type ListTransactionsRow struct {
@ -318,6 +364,205 @@ func (q *Queries) ListTransactionsByProfessional(ctx context.Context, arg ListTr
return items, nil
}
const listTransactionsPaginated = `-- name: ListTransactionsPaginated :many
SELECT t.id, t.fot_id, t.data_cobranca, t.tipo_evento, t.tipo_servico, t.professional_name, t.whatsapp, t.cpf, t.tabela_free, t.valor_free, t.valor_extra, t.descricao_extra, t.total_pagar, t.data_pagamento, t.pgto_ok, t.criado_em, t.atualizado_em, t.profissional_id, f.fot as fot_numero,
e.nome as empresa_nome,
c.nome as curso_nome,
a.ano_semestre as ano_formatura,
f.instituicao as instituicao_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
LEFT JOIN anos_formaturas a ON f.ano_formatura_id = a.id
ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2
`
type ListTransactionsPaginatedParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
}
type ListTransactionsPaginatedRow struct {
ID pgtype.UUID `json:"id"`
FotID pgtype.UUID `json:"fot_id"`
DataCobranca pgtype.Date `json:"data_cobranca"`
TipoEvento pgtype.Text `json:"tipo_evento"`
TipoServico pgtype.Text `json:"tipo_servico"`
ProfessionalName pgtype.Text `json:"professional_name"`
Whatsapp pgtype.Text `json:"whatsapp"`
Cpf pgtype.Text `json:"cpf"`
TabelaFree pgtype.Text `json:"tabela_free"`
ValorFree pgtype.Numeric `json:"valor_free"`
ValorExtra pgtype.Numeric `json:"valor_extra"`
DescricaoExtra pgtype.Text `json:"descricao_extra"`
TotalPagar pgtype.Numeric `json:"total_pagar"`
DataPagamento pgtype.Date `json:"data_pagamento"`
PgtoOk pgtype.Bool `json:"pgto_ok"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
FotNumero pgtype.Text `json:"fot_numero"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
CursoNome pgtype.Text `json:"curso_nome"`
AnoFormatura pgtype.Text `json:"ano_formatura"`
InstituicaoNome pgtype.Text `json:"instituicao_nome"`
}
func (q *Queries) ListTransactionsPaginated(ctx context.Context, arg ListTransactionsPaginatedParams) ([]ListTransactionsPaginatedRow, error) {
rows, err := q.db.Query(ctx, listTransactionsPaginated, arg.Limit, arg.Offset)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTransactionsPaginatedRow
for rows.Next() {
var i ListTransactionsPaginatedRow
if err := rows.Scan(
&i.ID,
&i.FotID,
&i.DataCobranca,
&i.TipoEvento,
&i.TipoServico,
&i.ProfessionalName,
&i.Whatsapp,
&i.Cpf,
&i.TabelaFree,
&i.ValorFree,
&i.ValorExtra,
&i.DescricaoExtra,
&i.TotalPagar,
&i.DataPagamento,
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
&i.FotNumero,
&i.EmpresaNome,
&i.CursoNome,
&i.AnoFormatura,
&i.InstituicaoNome,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTransactionsPaginatedFiltered = `-- name: ListTransactionsPaginatedFiltered :many
SELECT t.id, t.fot_id, t.data_cobranca, t.tipo_evento, t.tipo_servico, t.professional_name, t.whatsapp, t.cpf, t.tabela_free, t.valor_free, t.valor_extra, t.descricao_extra, t.total_pagar, t.data_pagamento, t.pgto_ok, t.criado_em, t.atualizado_em, t.profissional_id, f.fot as fot_numero,
e.nome as empresa_nome,
c.nome as curso_nome,
a.ano_semestre as ano_formatura,
f.instituicao as instituicao_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
LEFT JOIN anos_formaturas a ON f.ano_formatura_id = a.id
WHERE
($3::text = '' OR CAST(f.fot AS TEXT) ILIKE '%' || $3 || '%') AND
($4::text = '' OR CAST(t.data_cobranca AS TEXT) ILIKE '%' || $4 || '%') AND
($5::text = '' OR t.tipo_evento ILIKE '%' || $5 || '%') AND
($6::text = '' OR t.tipo_servico ILIKE '%' || $6 || '%') AND
($7::text = '' OR t.professional_name ILIKE '%' || $7 || '%')
ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2
`
type ListTransactionsPaginatedFilteredParams struct {
Limit int32 `json:"limit"`
Offset int32 `json:"offset"`
Fot string `json:"fot"`
Data string `json:"data"`
Evento string `json:"evento"`
Servico string `json:"servico"`
Nome string `json:"nome"`
}
type ListTransactionsPaginatedFilteredRow struct {
ID pgtype.UUID `json:"id"`
FotID pgtype.UUID `json:"fot_id"`
DataCobranca pgtype.Date `json:"data_cobranca"`
TipoEvento pgtype.Text `json:"tipo_evento"`
TipoServico pgtype.Text `json:"tipo_servico"`
ProfessionalName pgtype.Text `json:"professional_name"`
Whatsapp pgtype.Text `json:"whatsapp"`
Cpf pgtype.Text `json:"cpf"`
TabelaFree pgtype.Text `json:"tabela_free"`
ValorFree pgtype.Numeric `json:"valor_free"`
ValorExtra pgtype.Numeric `json:"valor_extra"`
DescricaoExtra pgtype.Text `json:"descricao_extra"`
TotalPagar pgtype.Numeric `json:"total_pagar"`
DataPagamento pgtype.Date `json:"data_pagamento"`
PgtoOk pgtype.Bool `json:"pgto_ok"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
FotNumero pgtype.Text `json:"fot_numero"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
CursoNome pgtype.Text `json:"curso_nome"`
AnoFormatura pgtype.Text `json:"ano_formatura"`
InstituicaoNome pgtype.Text `json:"instituicao_nome"`
}
func (q *Queries) ListTransactionsPaginatedFiltered(ctx context.Context, arg ListTransactionsPaginatedFilteredParams) ([]ListTransactionsPaginatedFilteredRow, error) {
rows, err := q.db.Query(ctx, listTransactionsPaginatedFiltered,
arg.Limit,
arg.Offset,
arg.Fot,
arg.Data,
arg.Evento,
arg.Servico,
arg.Nome,
)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTransactionsPaginatedFilteredRow
for rows.Next() {
var i ListTransactionsPaginatedFilteredRow
if err := rows.Scan(
&i.ID,
&i.FotID,
&i.DataCobranca,
&i.TipoEvento,
&i.TipoServico,
&i.ProfessionalName,
&i.Whatsapp,
&i.Cpf,
&i.TabelaFree,
&i.ValorFree,
&i.ValorExtra,
&i.DescricaoExtra,
&i.TotalPagar,
&i.DataPagamento,
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
&i.FotNumero,
&i.EmpresaNome,
&i.CursoNome,
&i.AnoFormatura,
&i.InstituicaoNome,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const sumTotalByFot = `-- name: SumTotalByFot :one
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
FROM financial_transactions

View file

@ -2,9 +2,9 @@
INSERT INTO financial_transactions (
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra,
total_pagar, data_pagamento, pgto_ok
total_pagar, data_pagamento, pgto_ok, profissional_id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15
) RETURNING *;
-- name: ListTransactionsByFot :many
@ -16,7 +16,7 @@ ORDER BY data_cobranca DESC;
SELECT t.*, f.fot as fot_numero
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
ORDER BY t.data_cobranca DESC;
ORDER BY t.data_cobranca DESC NULLS LAST;
-- name: SumTotalByFot :one
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
@ -53,3 +53,51 @@ WHERE
REGEXP_REPLACE(t.cpf, '\D', '', 'g') = REGEXP_REPLACE($2, '\D', '', 'g')
)
ORDER BY t.data_cobranca DESC;
-- name: ListTransactionsPaginated :many
SELECT t.*, f.fot as fot_numero,
e.nome as empresa_nome,
c.nome as curso_nome,
a.ano_semestre as ano_formatura,
f.instituicao as instituicao_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
LEFT JOIN anos_formaturas a ON f.ano_formatura_id = a.id
ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2;
-- name: ListTransactionsPaginatedFiltered :many
SELECT t.*, f.fot as fot_numero,
e.nome as empresa_nome,
c.nome as curso_nome,
a.ano_semestre as ano_formatura,
f.instituicao as instituicao_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
LEFT JOIN anos_formaturas a ON f.ano_formatura_id = a.id
WHERE
(@fot::text = '' OR CAST(f.fot AS TEXT) ILIKE '%' || @fot || '%') AND
(@data::text = '' OR CAST(t.data_cobranca AS TEXT) ILIKE '%' || @data || '%') AND
(@evento::text = '' OR t.tipo_evento ILIKE '%' || @evento || '%') AND
(@servico::text = '' OR t.tipo_servico ILIKE '%' || @servico || '%') AND
(@nome::text = '' OR t.professional_name ILIKE '%' || @nome || '%')
ORDER BY t.data_cobranca DESC NULLS LAST
LIMIT $1 OFFSET $2;
-- name: CountTransactions :one
SELECT COUNT(*) FROM financial_transactions;
-- name: CountTransactionsFiltered :one
SELECT COUNT(*)
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
WHERE
(@fot::text = '' OR CAST(f.fot AS TEXT) ILIKE '%' || @fot || '%') AND
(@data::text = '' OR CAST(t.data_cobranca AS TEXT) ILIKE '%' || @data || '%') AND
(@evento::text = '' OR t.tipo_evento ILIKE '%' || @evento || '%') AND
(@servico::text = '' OR t.tipo_servico ILIKE '%' || @servico || '%') AND
(@nome::text = '' OR t.professional_name ILIKE '%' || @nome || '%');

View file

@ -186,12 +186,40 @@ func (h *Handler) List(c *gin.Context) {
return
}
list, err := h.service.ListAll(c.Request.Context())
// Pagination
page := 1
limit := 50
if p := c.Query("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if l := c.Query("limit"); l != "" {
fmt.Sscanf(l, "%d", &limit)
}
// Filters
fot := c.Query("fot")
data := c.Query("data")
evento := c.Query("evento")
servico := c.Query("servico")
nome := c.Query("nome")
list, count, err := h.service.ListPaginated(c.Request.Context(), int32(page), int32(limit), FilterParams{
Fot: fot,
Data: data,
Evento: evento,
Servico: servico,
Nome: nome,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, list)
c.JSON(http.StatusOK, gin.H{
"data": list,
"total": count,
"page": page,
"limit": limit,
})
}
func (h *Handler) AutoFill(c *gin.Context) {
@ -291,3 +319,19 @@ func (h *Handler) SearchFot(c *gin.Context) {
}
c.JSON(http.StatusOK, results)
}
func (h *Handler) Import(c *gin.Context) {
var items []ImportFinanceItem
if err := c.ShouldBindJSON(&items); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
result, err := h.service.Import(c.Request.Context(), items)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, result)
}

View file

@ -2,17 +2,23 @@ package finance
import (
"context"
"fmt"
"photum-backend/internal/db/generated"
"strings"
"time"
"photum-backend/internal/profissionais"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
queries *generated.Queries
queries *generated.Queries
profService *profissionais.Service
}
func NewService(queries *generated.Queries) *Service {
return &Service{queries: queries}
func NewService(queries *generated.Queries, profService *profissionais.Service) *Service {
return &Service{queries: queries, profService: profService}
}
func (s *Service) Create(ctx context.Context, params generated.CreateTransactionParams) (generated.FinancialTransaction, error) {
@ -67,6 +73,50 @@ func (s *Service) ListAll(ctx context.Context) ([]generated.ListTransactionsRow,
return s.queries.ListTransactions(ctx)
}
type FilterParams struct {
Fot string
Data string
Evento string
Servico string
Nome string
}
func (s *Service) ListPaginated(ctx context.Context, page int32, limit int32, filters FilterParams) ([]generated.ListTransactionsPaginatedFilteredRow, int64, error) {
if page < 1 {
page = 1
}
if limit < 1 {
limit = 50
}
offset := (page - 1) * limit
rows, err := s.queries.ListTransactionsPaginatedFiltered(ctx, generated.ListTransactionsPaginatedFilteredParams{
Limit: limit,
Offset: offset,
Fot: filters.Fot,
Data: filters.Data,
Evento: filters.Evento,
Servico: filters.Servico,
Nome: filters.Nome,
})
if err != nil {
return nil, 0, err
}
count, err := s.queries.CountTransactionsFiltered(ctx, generated.CountTransactionsFilteredParams{
Fot: filters.Fot,
Data: filters.Data,
Evento: filters.Evento,
Servico: filters.Servico,
Nome: filters.Nome,
})
if err != nil {
count = 0
}
return rows, count, nil
}
func (s *Service) AutoFillSearch(ctx context.Context, fotNumber string) (generated.GetCadastroFotByFotJoinRow, error) {
return s.queries.GetCadastroFotByFotJoin(ctx, fotNumber)
}
@ -109,3 +159,213 @@ func (s *Service) updateFotExpenses(ctx context.Context, fotID pgtype.UUID) erro
GastosCaptacao: total,
})
}
// Import Logic
type ImportFinanceItem struct {
FOT string `json:"fot"`
Data string `json:"data"` // YYYY-MM-DD
TipoEvento string `json:"tipo_evento"`
TipoServico string `json:"tipo_servico"`
Nome string `json:"nome"`
Whatsapp string `json:"whatsapp"`
CPF string `json:"cpf"`
TabelaFree string `json:"tabela_free"`
ValorFree float64 `json:"valor_free"`
ValorExtra float64 `json:"valor_extra"`
DescricaoExtra string `json:"descricao_extra"`
TotalPagar float64 `json:"total_pagar"`
DataPgto string `json:"data_pgto"`
PgtoOK bool `json:"pgto_ok"`
}
type ImportFinanceResult struct {
Created int
Errors []string
}
func (s *Service) Import(ctx context.Context, items []ImportFinanceItem) (ImportFinanceResult, error) {
result := ImportFinanceResult{}
// Fetch all funcoes to map Name -> ID
funcs, err := s.queries.ListFuncoes(ctx)
if err != nil {
return result, fmt.Errorf("failed to list functions: %w", err)
}
funcMap := make(map[string]pgtype.UUID)
var defaultFuncID pgtype.UUID
if len(funcs) > 0 {
defaultFuncID = funcs[0].ID
}
for _, f := range funcs {
funcMap[strings.ToLower(f.Nome)] = f.ID
}
// 1. Bulk Upsert Professionals
profMap := make(map[string]profissionais.CreateProfissionalInput)
for _, item := range items {
if item.CPF == "" {
continue
}
cleanCPF := strings.ReplaceAll(item.CPF, ".", "")
cleanCPF = strings.ReplaceAll(cleanCPF, "-", "")
cleanCPF = strings.TrimSpace(cleanCPF)
if len(cleanCPF) > 20 {
cleanCPF = cleanCPF[:20]
}
if _, exists := profMap[cleanCPF]; !exists {
nm := item.Nome
if len(nm) > 100 {
nm = nm[:100]
}
cpf := cleanCPF
phone := item.Whatsapp
if len(phone) > 20 {
phone = phone[:20]
}
profMap[cleanCPF] = profissionais.CreateProfissionalInput{
Nome: nm,
CpfCnpjTitular: &cpf,
Whatsapp: &phone,
FuncaoProfissionalID: func() string {
if id, ok := funcMap[strings.ToLower(item.TipoServico)]; ok {
if id.Valid {
return fmt.Sprintf("%x", id.Bytes)
}
}
// Mapping heuristics for specific terms if needed
if strings.Contains(strings.ToLower(item.TipoServico), "foto") {
for k, v := range funcMap {
if strings.Contains(k, "foto") && v.Valid {
return fmt.Sprintf("%x", v.Bytes)
}
}
}
if defaultFuncID.Valid {
return fmt.Sprintf("%x", defaultFuncID.Bytes)
}
return ""
}(),
}
}
}
var profInputs []profissionais.CreateProfissionalInput
for _, p := range profMap {
profInputs = append(profInputs, p)
}
if len(profInputs) > 0 {
stats, errs := s.profService.Import(ctx, profInputs)
if len(errs) > 0 {
for _, e := range errs {
result.Errors = append(result.Errors, fmt.Sprintf("Professional Import Error: %v", e))
}
}
fmt.Printf("Professionals Imported: Created=%d, Updated=%d\n", stats.Created, stats.Updated)
}
// 2. Process Transactions
for i, item := range items {
if item.FOT == "" {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Missing FOT", i))
continue
}
fotRow, err := s.queries.GetCadastroFotByFOT(ctx, item.FOT)
var fotID pgtype.UUID
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: FOT %s not found", i, item.FOT))
} else {
fotID = fotRow.ID
}
var profID pgtype.UUID
if item.CPF != "" {
cleanCPF := strings.ReplaceAll(item.CPF, ".", "")
cleanCPF = strings.ReplaceAll(cleanCPF, "-", "")
cleanCPF = strings.TrimSpace(cleanCPF)
if len(cleanCPF) > 20 {
cleanCPF = cleanCPF[:20]
}
prof, err := s.queries.GetProfissionalByCPF(ctx, pgtype.Text{String: cleanCPF, Valid: true})
if err == nil {
profID = prof.ID
}
}
var dataCobranca pgtype.Date
if item.Data != "" {
t, err := time.Parse("2006-01-02", item.Data)
if err == nil {
dataCobranca = pgtype.Date{Time: t, Valid: true}
} else {
t2, err2 := time.Parse("02/01/2006", item.Data)
if err2 == nil {
dataCobranca = pgtype.Date{Time: t2, Valid: true}
}
}
}
var dataPgto pgtype.Date
if item.DataPgto != "" {
t, err := time.Parse("2006-01-02", item.DataPgto)
if err == nil {
dataPgto = pgtype.Date{Time: t, Valid: true}
} else {
t2, err2 := time.Parse("02/01/2006", item.DataPgto)
if err2 == nil {
dataPgto = pgtype.Date{Time: t2, Valid: true}
}
}
}
params := generated.CreateTransactionParams{
FotID: fotID,
DataCobranca: dataCobranca,
TipoEvento: pgtype.Text{String: item.TipoEvento, Valid: item.TipoEvento != ""},
TipoServico: pgtype.Text{String: item.TipoServico, Valid: item.TipoServico != ""},
ProfessionalName: pgtype.Text{String: item.Nome, Valid: item.Nome != ""},
Whatsapp: pgtype.Text{String: limitStr(item.Whatsapp, 50), Valid: item.Whatsapp != ""},
Cpf: pgtype.Text{String: limitStr(item.CPF, 20), Valid: item.CPF != ""},
TabelaFree: pgtype.Text{String: item.TabelaFree, Valid: item.TabelaFree != ""},
ValorFree: toNumeric(item.ValorFree),
ValorExtra: toNumeric(item.ValorExtra),
DescricaoExtra: pgtype.Text{String: item.DescricaoExtra, Valid: item.DescricaoExtra != ""},
TotalPagar: toNumeric(item.TotalPagar),
DataPagamento: dataPgto,
PgtoOk: pgtype.Bool{Bool: item.PgtoOK, Valid: true},
ProfissionalID: profID,
}
_, err = s.Create(ctx, params)
if err != nil {
result.Errors = append(result.Errors, fmt.Sprintf("Row %d: Saved Error %v", i, err))
} else {
result.Created++
}
}
return result, nil
}
func toNumeric(f float64) pgtype.Numeric {
var n pgtype.Numeric
n.Scan(fmt.Sprintf("%.2f", f))
return n
}
func limitStr(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useRef } from "react";
import {
Download,
Plus,
@ -8,7 +8,9 @@ import {
X,
AlertCircle,
Search,
Upload,
} from "lucide-react";
import { useNavigate } from "react-router-dom";
interface FinancialTransaction {
id: string;
@ -38,6 +40,7 @@ interface FinancialTransaction {
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
const Finance: React.FC = () => {
const navigate = useNavigate();
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
@ -55,6 +58,33 @@ const Finance: React.FC = () => {
const [tiposEventos, setTiposEventos] = useState<any[]>([]);
const [tiposServicos, setTiposServicos] = useState<any[]>([]);
// Filters State (Moved up)
const [filters, setFilters] = useState({
fot: "",
data: "",
evento: "",
servico: "",
nome: "",
status: "",
});
// Pagination State
const [page, setPage] = useState(1);
const [limit, setLimit] = useState(50);
const [total, setTotal] = useState(0);
// Scroll Sync Refs
const topScrollRef = useRef<HTMLDivElement>(null);
const tableScrollRef = useRef<HTMLDivElement>(null);
const handleScroll = (source: 'top' | 'table') => {
if (source === 'top' && topScrollRef.current && tableScrollRef.current) {
tableScrollRef.current.scrollLeft = topScrollRef.current.scrollLeft;
} else if (source === 'table' && topScrollRef.current && tableScrollRef.current) {
topScrollRef.current.scrollLeft = tableScrollRef.current.scrollLeft;
}
};
// Form State
const [formData, setFormData] = useState<Partial<FinancialTransaction>>({
fot: 0,
@ -109,27 +139,39 @@ const Finance: React.FC = () => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/api/finance`, {
const queryParams = new URLSearchParams({
page: page.toString(),
limit: limit.toString(),
fot: filters.fot || "",
data: filters.data || "",
evento: filters.evento || "",
servico: filters.servico || "",
nome: filters.nome || "",
});
const res = await fetch(`${API_BASE_URL}/api/finance?${queryParams.toString()}`, {
headers: {
"Authorization": `Bearer ${token}`
}
});
if (res.status === 401) throw new Error("Não autorizado");
if (!res.ok) throw new Error("Falha ao carregar transações");
const data = await res.json();
const result = await res.json();
const data = result.data || result; // Fallback if API returns array (e.g. filtered by FOT)
const count = result.total || data.length;
setTotal(count);
// Map Backend DTO to Frontend Interface
const mapped = data.map((item: any) => ({
const mapped = (Array.isArray(data) ? data : []).map((item: any) => ({
id: item.id,
fot: item.fot_numero || 0,
// Format to DD/MM/YYYY for display, keep YYYY-MM-DD for editing if needed?
// Actually, logic uses `data` for display. `data_cobranca` comes as ISO.
data: item.data_cobranca ? new Date(item.data_cobranca).toLocaleDateString("pt-BR", {timeZone: "UTC"}) : "",
dataRaw: item.data_cobranca ? item.data_cobranca.split("T")[0] : "", // Store raw for edit
curso: "",
instituicao: "",
anoFormatura: 0,
empresa: "",
dataRaw: item.data_cobranca ? item.data_cobranca.split("T")[0] : "",
curso: item.curso_nome || "",
instituicao: item.instituicao_nome || "",
anoFormatura: item.ano_formatura || "",
empresa: item.empresa_nome || "",
tipoEvento: item.tipo_evento,
tipoServico: item.tipo_servico,
nome: item.professional_name,
@ -172,17 +214,16 @@ const Finance: React.FC = () => {
useEffect(() => {
loadTransactions();
loadAuxiliaryData();
}, []);
}, [page, limit]); // Refresh on page/limit change
// Filters
const [filters, setFilters] = useState({
fot: "",
data: "",
evento: "",
servico: "",
nome: "",
status: "",
});
// Debounce filter changes
useEffect(() => {
const timer = setTimeout(() => {
setPage(1); // Reset to page 1 on filter change
loadTransactions();
}, 500);
return () => clearTimeout(timer);
}, [filters]);
// Advanced date filters
const [dateFilters, setDateFilters] = useState({
@ -674,8 +715,15 @@ const Finance: React.FC = () => {
<h1 className="text-3xl font-serif font-bold text-gray-900">Extrato</h1>
<p className="text-gray-500 text-sm">Controle financeiro e transações</p>
</div>
<button
onClick={() => {
<div className="flex gap-2">
<button
onClick={() => navigate("/importacao?tab=financeiro")}
className="bg-blue-600 text-white px-4 py-2 rounded shadow hover:bg-blue-700 transition flex items-center gap-2"
>
<Upload size={18} /> Importar Dados
</button>
<button
onClick={() => {
setFormData({ // Clear form to initial state
fot: 0,
data: new Date().toISOString().split("T")[0],
@ -705,6 +753,7 @@ const Finance: React.FC = () => {
<Plus size={18}/> Nova Transação
</button>
</div>
</div>
{/* Advanced Date Filters */}
<div className="mb-4">
@ -763,12 +812,50 @@ const Finance: React.FC = () => {
)}
</div>
{/* Pagination Controls (Top) */}
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-b rounded-t-lg">
<span className="text-xs text-gray-600">
Mostrando {transactions.length} de {total} registros
</span>
<div className="flex gap-2 items-center">
<button
disabled={page === 1 || loading}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Anterior
</button>
<span className="text-xs font-medium">Página {page}</span>
<button
disabled={page * limit >= total || loading}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Próxima
</button>
</div>
</div>
{/* List */}
<div className="bg-white rounded shadow overflow-x-auto">
<table className="w-full text-xs text-left whitespace-nowrap">
<div className="bg-white rounded shadow flex flex-col border-t-0 rounded-t-none">
{/* Top Scrollbar Sync */}
<div
ref={topScrollRef}
onScroll={() => handleScroll('top')}
className="overflow-x-auto w-full"
>
<div className="h-1 min-w-[1500px]"></div>
</div>
<div
ref={tableScrollRef}
onScroll={() => handleScroll('table')}
className="overflow-x-auto"
>
<table className="w-full text-xs text-left whitespace-nowrap min-w-[1500px]">
<thead className="bg-gray-100 border-b">
<tr>
{["FOT", "Data Evento", "Evento", "Serviço", "Nome", "WhatsApp", "CPF", "Tab. Free", "V. Free", "V. Extra", "Desc. Extra", "Total", "Dt. Pgto", "OK"].map(h => (
{["FOT", "Data Evento", "Curso", "Instituição", "Ano", "Empresa", "Evento", "Serviço", "Nome", "WhatsApp", "CPF", "Tab. Free", "V. Free", "V. Extra", "Desc. Extra", "Total", "Dt. Pgto", "OK"].map(h => (
<th key={h} className="px-3 py-2 font-semibold text-gray-700 align-top">
<div className="flex flex-col gap-1">
<span>{h}</span>
@ -785,7 +872,27 @@ const Finance: React.FC = () => {
</tr>
</thead>
<tbody className="divide-y relative">
{sortedTransactions.map((t, index) => {
{loading && (
<tr>
<td colSpan={14} className="p-8 text-center text-gray-500">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-green mb-2"></div>
Carregando...
</div>
</td>
</tr>
)}
{loading && (
<tr>
<td colSpan={14} className="p-8 text-center text-gray-500">
<div className="flex flex-col items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-brand-green mb-2"></div>
Carregando...
</div>
</td>
</tr>
)}
{!loading && sortedTransactions.map((t, index) => {
const isNewFot = index > 0 && t.fot !== sortedTransactions[index - 1].fot;
// Check if this is the last item of the group (or list) to show summary
@ -801,6 +908,10 @@ const Finance: React.FC = () => {
>
<td className="px-3 py-2 font-bold">{t.fot || "?"}</td>
<td className="px-3 py-2">{t.data}</td>
<td className="px-3 py-2">{t.curso}</td>
<td className="px-3 py-2">{t.instituicao}</td>
<td className="px-3 py-2">{t.anoFormatura}</td>
<td className="px-3 py-2">{t.empresa}</td>
<td className="px-3 py-2">{t.tipoEvento}</td>
<td className="px-3 py-2">{t.tipoServico}</td>
<td className="px-3 py-2">{t.nome}</td>
@ -852,6 +963,31 @@ const Finance: React.FC = () => {
{sortedTransactions.length === 0 && !loading && (
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
)}
</div>
{/* Pagination Controls */}
<div className="flex justify-between items-center px-4 py-2 bg-gray-50 border-t rounded-b-lg">
<span className="text-xs text-gray-600">
Mostrando {transactions.length} de {total} registros
</span>
<div className="flex gap-2 items-center">
<button
disabled={page === 1 || loading}
onClick={() => setPage(p => Math.max(1, p - 1))}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Anterior
</button>
<span className="text-xs font-medium">Página {page}</span>
<button
disabled={page * limit >= total || loading}
onClick={() => setPage(p => p + 1)}
className="px-3 py-1 border rounded bg-white hover:bg-gray-100 disabled:opacity-50 text-xs"
>
Próxima
</button>
</div>
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@ import { Upload, FileText, CheckCircle, AlertTriangle, Calendar, Database, UserP
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
type ImportType = 'fot' | 'agenda' | 'profissionais';
type ImportType = 'fot' | 'agenda' | 'profissionais' | 'financeiro';
interface ImportFotInput {
fot: string;
@ -63,9 +63,30 @@ interface ImportProfissionalInput {
// Add other fields as needed
}
interface ImportFinanceInput {
fot: string;
data: string;
tipo_evento: string;
tipo_servico: string;
nome: string;
whatsapp: string;
cpf: string;
tabela_free: string;
valor_free: number;
valor_extra: number;
descricao_extra: string;
total_pagar: number;
data_pgto: string;
pgto_ok: boolean;
}
export const ImportData: React.FC = () => {
const { token } = useAuth();
const [activeTab, setActiveTab] = useState<ImportType>('fot');
// Read initial tab from URL
const query = new URLSearchParams(window.location.search);
const initialTab = (query.get('tab') as ImportType) || 'fot';
const [activeTab, setActiveTab] = useState<ImportType>(initialTab);
// Generic data state (can be Fot, Agenda, Profissionais)
const [data, setData] = useState<any[]>([]);
@ -132,11 +153,149 @@ export const ImportData: React.FC = () => {
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
const rows = jsonData as any[];
let mappedData: any[] = [];
let skipped = 0;
// Start from row 1 (skip header)
if (activeTab === 'financeiro') {
// Dynamic Header Mapping
// 1. Find header row (look for "FOT" and "Nome")
let headerIdx = -1;
const colMap: {[key: string]: number} = {};
for (let i = 0; i < 20 && i < rows.length; i++) {
const rowStrings = (rows[i] as any[]).map(c => String(c).toLowerCase().trim());
if (rowStrings.some(s => s === 'fot' || s === 'nome' || s === 'cpf')) {
headerIdx = i;
rowStrings.forEach((h, idx) => {
if (h === 'fot' || h.includes('contrato')) colMap['fot'] = idx;
else if (h === 'data' || h.includes('dt evento')) colMap['data'] = idx;
else if (h === 'evento' || h.includes('tp evento')) colMap['evento'] = idx;
else if (h === 'serviço' || h === 'servico' || h.includes('função') || h.includes('tp serv')) colMap['servico'] = idx;
else if (h === 'nome' || h === 'profissional') colMap['nome'] = idx;
else if (h === 'whatsapp' || h === 'whats' || h === 'tel' || h === 'cel') colMap['whatsapp'] = idx;
else if (h === 'cpf') colMap['cpf'] = idx;
else if (h.includes('tab') || h.includes('tabela')) colMap['tabela'] = idx;
else if (h.includes('v. free') || h.includes('valor free') || h === 'cache') colMap['vfree'] = idx;
else if (h.includes('v. extra') || h.includes('valor extra')) colMap['vextra'] = idx;
else if (h.includes('desc') || h.includes('obs extra')) colMap['descextra'] = idx;
else if (h === 'total' || h.includes('total')) colMap['total'] = idx;
else if (h.includes('dt pgto') || h.includes('data pag')) colMap['datapgto'] = idx;
else if (h === 'ok' || h.includes('pago') || h === 'status') colMap['pgtook'] = idx;
});
break;
}
}
if (headerIdx === -1) {
// Fallback to index based if header not found
headerIdx = 0;
colMap['fot'] = 0; colMap['data'] = 1; colMap['evento'] = 2; colMap['servico'] = 3;
colMap['nome'] = 4; colMap['whatsapp'] = 5; colMap['cpf'] = 6; colMap['tabela'] = 7;
colMap['vfree'] = 8; colMap['vextra'] = 9; colMap['descextra'] = 10; colMap['total'] = 11;
colMap['datapgto'] = 12; colMap['pgtook'] = 13;
}
// Iterate data rows
for (let i = headerIdx + 1; i < rows.length; i++) {
const row = rows[i] as any[];
if (!row || row.length === 0) continue;
const getCol = (key: string) => {
const idx = colMap[key];
if (idx === undefined || idx < 0) return "";
return row[idx] !== undefined ? String(row[idx]).trim() : "";
};
const getRawCol = (key: string) => {
const idx = colMap[key];
if (idx === undefined || idx < 0) return undefined;
return row[idx];
};
const fot = getCol('fot');
const nome = getCol('nome');
const totalPagar = getCol('total');
const valorFree = getCol('vfree');
// Skip if Nome is empty AND (Total is 0 or empty AND Free is 0 or empty)
// AND FOT checks.
const isTotalZero = !totalPagar || parseFloat(totalPagar.replace(/[R$\s.]/g, '').replace(',', '.')) === 0;
const isFreeZero = !valorFree || parseFloat(valorFree.replace(/[R$\s.]/g, '').replace(',', '.')) === 0;
if (!fot && !nome) {
skipped++;
continue; // Basic skip for completely empty rows
}
if (!nome && isTotalZero && isFreeZero) {
skipped++;
continue; // Skip garbage rows with just FOT and no name/value
}
// VALIDATION STRICT: Skip if FOT and Nome are empty or just "-"
if ((!fot || fot.length < 1 || fot === '-') && (!nome || nome.length < 2)) {
skipped++;
continue;
}
const parseVal = (str: string) => {
if (!str) return 0;
// Handle 1.234,56 or 1234.56
let s = str.replace(/[R$\s]/g, '');
if (s.includes(',') && s.includes('.')) {
// 1.234,56 -> remove dots, replace comma with dot
s = s.replace(/\./g, '').replace(',', '.');
} else if (s.includes(',')) {
s = s.replace(',', '.');
}
return parseFloat(s) || 0;
};
const parseDateCol = (key: string) => {
const raw = getRawCol(key);
if (typeof raw === 'number') {
const dateObj = new Date(Math.round((raw - 25569)*86400*1000));
// Return YYYY-MM-DD for backend consistency
return dateObj.toISOString().split('T')[0];
}
const str = getCol(key);
// Try to fix DD/MM/YYYY to YYYY-MM-DD
if (str.includes('/')) {
const parts = str.split('/');
if (parts.length === 3) return `${parts[2]}-${parts[1]}-${parts[0]}`;
}
return str;
};
const item: ImportFinanceInput = {
fot: fot,
data: parseDateCol('data'),
tipo_evento: getCol('evento'),
tipo_servico: getCol('servico'),
nome: getCol('nome'),
whatsapp: getCol('whatsapp'),
cpf: getCol('cpf'),
tabela_free: getCol('tabela'),
valor_free: parseVal(getCol('vfree')),
valor_extra: parseVal(getCol('vextra')),
descricao_extra: getCol('descextra'),
total_pagar: parseVal(getCol('total')),
data_pgto: parseDateCol('datapgto'),
pgto_ok: getCol('pgtook').toLowerCase().includes('sim') || getCol('pgtook').toLowerCase() === 'ok',
};
mappedData.push(item);
}
setData(mappedData);
setSkippedCount(0);
setResult(null);
return; // EXIT early for Finance
}
// Start from row 1 (skip header) for OTHER inputs
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length === 0) continue;
@ -367,6 +526,7 @@ export const ImportData: React.FC = () => {
if (activeTab === 'fot') endpoint = '/api/import/fot';
else if (activeTab === 'agenda') endpoint = '/api/import/agenda';
else if (activeTab === 'profissionais') endpoint = '/api/profissionais/import';
else if (activeTab === 'financeiro') endpoint = '/api/finance/import';
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: "POST",
@ -473,6 +633,17 @@ export const ImportData: React.FC = () => {
<UserPlus className="w-4 h-4" />
Profissionais
</button>
<button
onClick={() => handleTabChange('financeiro')}
className={`${
activeTab === 'financeiro'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
} whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2`}
>
<FileText className="w-4 h-4" />
Financeiro
</button>
</nav>
</div>
@ -487,6 +658,9 @@ export const ImportData: React.FC = () => {
{activeTab === 'profissionais' && (
<p><strong>Colunas Esperadas (A-J):</strong> Nome, Função, Endereço, Cidade, UF, Whatsapp, <strong>CPF/CNPJ</strong> (Obrigatório), Banco, Agencia, Conta PIX.</p>
)}
{activeTab === 'financeiro' && (
<p><strong>Colunas Esperadas (A-N):</strong> FOT, Data, Tipo Evento, Tipo Serviço, Nome, Whatsapp, CPF, Tabela Free, Valor Free, Valor Extra, Desc. Extra, Total, Data Pgto, Pgto OK.</p>
)}
</div>
<div className="bg-white p-6 rounded-lg shadow space-y-4">
@ -561,6 +735,16 @@ export const ImportData: React.FC = () => {
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Cidade/UF</th>
</>
)}
{activeTab === 'financeiro' && (
<>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">FOT</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Data</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Profissional</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Serviço</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Total</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider bg-gray-50">Status</th>
</>
)}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
@ -602,6 +786,22 @@ export const ImportData: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.cidade}/{row.uf}</td>
</>
)}
{activeTab === 'financeiro' && (
<>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">{row.fot}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.data}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.nome}<br/><span className="text-xs text-gray-400">{row.cpf}</span></td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{row.tipo_servico}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">R$ {row.total_pagar.toFixed(2)}</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{row.pgto_ok ? (
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded-full">Pago</span>
) : (
<span className="bg-yellow-100 text-yellow-800 text-xs px-2 py-1 rounded-full">Pendente</span>
)}
</td>
</>
)}
</tr>
))}
</tbody>