From a51401d9ba562c680bfaa4b720792dd361f7da4d Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Mon, 2 Feb 2026 19:16:37 -0300 Subject: [PATCH] =?UTF-8?q?feat(finance):=20overhaul=20completo=20do=20fin?= =?UTF-8?q?anceiro=20(Import,=20Filtros,=20UI)=20-=20Melhora=20Importa?= =?UTF-8?q?=C3=A7=C3=A3o:=20ignora=20linhas=20vazias/inv=C3=A1lidas=20auto?= =?UTF-8?q?maticamente.=20-=20Filtros=20Server-Side:=20busca=20em=20todas?= =?UTF-8?q?=20as=20p=C3=A1ginas=20(FOT,=20Nome,=20etc.).=20-=20Colunas=20N?= =?UTF-8?q?ovas:=20adiciona=20Curso,=20Institui=C3=A7=C3=A3o,=20Ano=20e=20?= =?UTF-8?q?Empresa=20na=20tabela.=20-=20UI/UX:=20Corrige=20ordena=C3=A7?= =?UTF-8?q?=C3=A3o=20(vazios=20no=20fim)=20e=20adiciona=20scrollbar=20no?= =?UTF-8?q?=20topo.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/api/main.go | 3 +- .../generated/financial_transactions.sql.go | 251 ++++++++++++++++- .../db/queries/financial_transactions.sql | 54 +++- backend/internal/finance/handler.go | 48 +++- backend/internal/finance/service.go | 266 +++++++++++++++++- frontend/pages/Finance.tsx | 192 +++++++++++-- frontend/pages/ImportData.tsx | 208 +++++++++++++- 7 files changed, 978 insertions(+), 44 deletions(-) diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 990c5d7..4b37263 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -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) diff --git a/backend/internal/db/generated/financial_transactions.sql.go b/backend/internal/db/generated/financial_transactions.sql.go index 255c365..4ad85ed 100644 --- a/backend/internal/db/generated/financial_transactions.sql.go +++ b/backend/internal/db/generated/financial_transactions.sql.go @@ -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 diff --git a/backend/internal/db/queries/financial_transactions.sql b/backend/internal/db/queries/financial_transactions.sql index 2d604ee..69e9386 100644 --- a/backend/internal/db/queries/financial_transactions.sql +++ b/backend/internal/db/queries/financial_transactions.sql @@ -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 || '%'); diff --git a/backend/internal/finance/handler.go b/backend/internal/finance/handler.go index b5cc783..1e50225 100644 --- a/backend/internal/finance/handler.go +++ b/backend/internal/finance/handler.go @@ -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) +} diff --git a/backend/internal/finance/service.go b/backend/internal/finance/service.go index 5d3798c..1beceeb 100644 --- a/backend/internal/finance/service.go +++ b/backend/internal/finance/service.go @@ -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 +} diff --git a/frontend/pages/Finance.tsx b/frontend/pages/Finance.tsx index e6321aa..e29beca 100644 --- a/frontend/pages/Finance.tsx +++ b/frontend/pages/Finance.tsx @@ -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([]); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); @@ -54,6 +57,33 @@ const Finance: React.FC = () => { const [error, setError] = useState(""); const [tiposEventos, setTiposEventos] = useState([]); const [tiposServicos, setTiposServicos] = useState([]); + + // 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(null); + const tableScrollRef = useRef(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>({ @@ -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(); - }, []); - - // Filters - const [filters, setFilters] = useState({ - fot: "", - data: "", - evento: "", - servico: "", - nome: "", - status: "", - }); + }, [page, limit]); // Refresh on page/limit change + + // 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 = () => {

Extrato

Controle financeiro e transações

- + + {/* Advanced Date Filters */}
@@ -763,12 +812,50 @@ const Finance: React.FC = () => { )}
+ {/* Pagination Controls (Top) */} +
+ + Mostrando {transactions.length} de {total} registros + +
+ + Página {page} + +
+
+ {/* List */} -
- +
+ {/* Top Scrollbar Sync */} +
handleScroll('top')} + className="overflow-x-auto w-full" + > +
+
+ +
handleScroll('table')} + className="overflow-x-auto" + > +
- {["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 => ( - {sortedTransactions.map((t, index) => { + {loading && ( + + + + )} + {loading && ( + + + + )} + {!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 = () => { > + + + + @@ -852,6 +963,31 @@ const Finance: React.FC = () => { {sortedTransactions.length === 0 && !loading && (
Nenhuma transação encontrada.
)} + + + {/* Pagination Controls */} +
+ + Mostrando {transactions.length} de {total} registros + +
+ + Página {page} + +
+
diff --git a/frontend/pages/ImportData.tsx b/frontend/pages/ImportData.tsx index e9ca08e..530d57a 100644 --- a/frontend/pages/ImportData.tsx +++ b/frontend/pages/ImportData.tsx @@ -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('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(initialTab); // Generic data state (can be Fot, Agenda, Profissionais) const [data, setData] = useState([]); @@ -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; + + 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) + // 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 = () => { Profissionais + @@ -487,6 +658,9 @@ export const ImportData: React.FC = () => { {activeTab === 'profissionais' && (

Colunas Esperadas (A-J): Nome, Função, Endereço, Cidade, UF, Whatsapp, CPF/CNPJ (Obrigatório), Banco, Agencia, Conta PIX.

)} + {activeTab === 'financeiro' && ( +

Colunas Esperadas (A-N): FOT, Data, Tipo Evento, Tipo Serviço, Nome, Whatsapp, CPF, Tabela Free, Valor Free, Valor Extra, Desc. Extra, Total, Data Pgto, Pgto OK.

+ )}
@@ -561,6 +735,16 @@ export const ImportData: React.FC = () => {
)} + {activeTab === 'financeiro' && ( + <> + + + + + + + + )} @@ -602,6 +786,22 @@ export const ImportData: React.FC = () => { )} + {activeTab === 'financeiro' && ( + <> + + + + + + + + )} ))}
{h} @@ -785,7 +872,27 @@ const Finance: React.FC = () => {
+
+
+ Carregando... +
+
+
+
+ Carregando... +
+
{t.fot || "?"} {t.data}{t.curso}{t.instituicao}{t.anoFormatura}{t.empresa} {t.tipoEvento} {t.tipoServico} {t.nome} Cidade/UFFOTDataProfissionalServiçoTotalStatus
{row.cidade}/{row.uf}{row.fot}{row.data}{row.nome}
{row.cpf}
{row.tipo_servico}R$ {row.total_pagar.toFixed(2)} + {row.pgto_ok ? ( + Pago + ) : ( + Pendente + )} +