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:
parent
542c8d4388
commit
a51401d9ba
7 changed files with 978 additions and 44 deletions
|
|
@ -103,7 +103,7 @@ func main() {
|
||||||
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
|
||||||
logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg))
|
logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg))
|
||||||
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
|
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
|
||||||
financeHandler := finance.NewHandler(finance.NewService(queries))
|
financeHandler := finance.NewHandler(finance.NewService(queries, profissionaisService))
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
|
|
@ -259,6 +259,7 @@ func main() {
|
||||||
financeGroup := api.Group("/finance")
|
financeGroup := api.Group("/finance")
|
||||||
{
|
{
|
||||||
financeGroup.POST("", financeHandler.Create)
|
financeGroup.POST("", financeHandler.Create)
|
||||||
|
financeGroup.POST("/import", financeHandler.Import)
|
||||||
financeGroup.GET("", financeHandler.List)
|
financeGroup.GET("", financeHandler.List)
|
||||||
financeGroup.GET("/autofill", financeHandler.AutoFill)
|
financeGroup.GET("/autofill", financeHandler.AutoFill)
|
||||||
financeGroup.GET("/fot-events", financeHandler.GetFotEvents)
|
financeGroup.GET("/fot-events", financeHandler.GetFotEvents)
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,57 @@ import (
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const createTransaction = `-- name: CreateTransaction :one
|
||||||
INSERT INTO financial_transactions (
|
INSERT INTO financial_transactions (
|
||||||
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
|
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
|
||||||
whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra,
|
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 (
|
) 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
|
) 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"`
|
TotalPagar pgtype.Numeric `json:"total_pagar"`
|
||||||
DataPagamento pgtype.Date `json:"data_pagamento"`
|
DataPagamento pgtype.Date `json:"data_pagamento"`
|
||||||
PgtoOk pgtype.Bool `json:"pgto_ok"`
|
PgtoOk pgtype.Bool `json:"pgto_ok"`
|
||||||
|
ProfissionalID pgtype.UUID `json:"profissional_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (FinancialTransaction, error) {
|
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.TotalPagar,
|
||||||
arg.DataPagamento,
|
arg.DataPagamento,
|
||||||
arg.PgtoOk,
|
arg.PgtoOk,
|
||||||
|
arg.ProfissionalID,
|
||||||
)
|
)
|
||||||
var i FinancialTransaction
|
var i FinancialTransaction
|
||||||
err := row.Scan(
|
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
|
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
|
FROM financial_transactions t
|
||||||
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
|
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 {
|
type ListTransactionsRow struct {
|
||||||
|
|
@ -318,6 +364,205 @@ func (q *Queries) ListTransactionsByProfessional(ctx context.Context, arg ListTr
|
||||||
return items, nil
|
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
|
const sumTotalByFot = `-- name: SumTotalByFot :one
|
||||||
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
|
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
|
||||||
FROM financial_transactions
|
FROM financial_transactions
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,9 @@
|
||||||
INSERT INTO financial_transactions (
|
INSERT INTO financial_transactions (
|
||||||
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
|
fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name,
|
||||||
whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra,
|
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 (
|
) 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 *;
|
) RETURNING *;
|
||||||
|
|
||||||
-- name: ListTransactionsByFot :many
|
-- name: ListTransactionsByFot :many
|
||||||
|
|
@ -16,7 +16,7 @@ ORDER BY data_cobranca DESC;
|
||||||
SELECT t.*, f.fot as fot_numero
|
SELECT t.*, f.fot as fot_numero
|
||||||
FROM financial_transactions t
|
FROM financial_transactions t
|
||||||
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
|
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
|
-- name: SumTotalByFot :one
|
||||||
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
|
SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC
|
||||||
|
|
@ -53,3 +53,51 @@ WHERE
|
||||||
REGEXP_REPLACE(t.cpf, '\D', '', 'g') = REGEXP_REPLACE($2, '\D', '', 'g')
|
REGEXP_REPLACE(t.cpf, '\D', '', 'g') = REGEXP_REPLACE($2, '\D', '', 'g')
|
||||||
)
|
)
|
||||||
ORDER BY t.data_cobranca DESC;
|
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 || '%');
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,40 @@ func (h *Handler) List(c *gin.Context) {
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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) {
|
func (h *Handler) AutoFill(c *gin.Context) {
|
||||||
|
|
@ -291,3 +319,19 @@ func (h *Handler) SearchFot(c *gin.Context) {
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, results)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,23 @@ package finance
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"photum-backend/internal/db/generated"
|
"photum-backend/internal/db/generated"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"photum-backend/internal/profissionais"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
queries *generated.Queries
|
queries *generated.Queries
|
||||||
|
profService *profissionais.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(queries *generated.Queries) *Service {
|
func NewService(queries *generated.Queries, profService *profissionais.Service) *Service {
|
||||||
return &Service{queries: queries}
|
return &Service{queries: queries, profService: profService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Create(ctx context.Context, params generated.CreateTransactionParams) (generated.FinancialTransaction, error) {
|
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)
|
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) {
|
func (s *Service) AutoFillSearch(ctx context.Context, fotNumber string) (generated.GetCadastroFotByFotJoinRow, error) {
|
||||||
return s.queries.GetCadastroFotByFotJoin(ctx, fotNumber)
|
return s.queries.GetCadastroFotByFotJoin(ctx, fotNumber)
|
||||||
}
|
}
|
||||||
|
|
@ -109,3 +159,213 @@ func (s *Service) updateFotExpenses(ctx context.Context, fotID pgtype.UUID) erro
|
||||||
GastosCaptacao: total,
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Plus,
|
Plus,
|
||||||
|
|
@ -8,7 +8,9 @@ import {
|
||||||
X,
|
X,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Search,
|
Search,
|
||||||
|
Upload,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
interface FinancialTransaction {
|
interface FinancialTransaction {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -38,6 +40,7 @@ interface FinancialTransaction {
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||||
|
|
||||||
const Finance: React.FC = () => {
|
const Finance: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
|
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
|
||||||
const [showAddModal, setShowAddModal] = useState(false);
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
|
@ -55,6 +58,33 @@ const Finance: React.FC = () => {
|
||||||
const [tiposEventos, setTiposEventos] = useState<any[]>([]);
|
const [tiposEventos, setTiposEventos] = useState<any[]>([]);
|
||||||
const [tiposServicos, setTiposServicos] = 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
|
// Form State
|
||||||
const [formData, setFormData] = useState<Partial<FinancialTransaction>>({
|
const [formData, setFormData] = useState<Partial<FinancialTransaction>>({
|
||||||
fot: 0,
|
fot: 0,
|
||||||
|
|
@ -109,27 +139,39 @@ const Finance: React.FC = () => {
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
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: {
|
headers: {
|
||||||
"Authorization": `Bearer ${token}`
|
"Authorization": `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (res.status === 401) throw new Error("Não autorizado");
|
if (res.status === 401) throw new Error("Não autorizado");
|
||||||
if (!res.ok) throw new Error("Falha ao carregar transações");
|
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
|
// Map Backend DTO to Frontend Interface
|
||||||
const mapped = data.map((item: any) => ({
|
const mapped = (Array.isArray(data) ? data : []).map((item: any) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
fot: item.fot_numero || 0,
|
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"}) : "",
|
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
|
dataRaw: item.data_cobranca ? item.data_cobranca.split("T")[0] : "",
|
||||||
curso: "",
|
curso: item.curso_nome || "",
|
||||||
instituicao: "",
|
instituicao: item.instituicao_nome || "",
|
||||||
anoFormatura: 0,
|
anoFormatura: item.ano_formatura || "",
|
||||||
empresa: "",
|
empresa: item.empresa_nome || "",
|
||||||
tipoEvento: item.tipo_evento,
|
tipoEvento: item.tipo_evento,
|
||||||
tipoServico: item.tipo_servico,
|
tipoServico: item.tipo_servico,
|
||||||
nome: item.professional_name,
|
nome: item.professional_name,
|
||||||
|
|
@ -172,17 +214,16 @@ const Finance: React.FC = () => {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTransactions();
|
loadTransactions();
|
||||||
loadAuxiliaryData();
|
loadAuxiliaryData();
|
||||||
}, []);
|
}, [page, limit]); // Refresh on page/limit change
|
||||||
|
|
||||||
// Filters
|
// Debounce filter changes
|
||||||
const [filters, setFilters] = useState({
|
useEffect(() => {
|
||||||
fot: "",
|
const timer = setTimeout(() => {
|
||||||
data: "",
|
setPage(1); // Reset to page 1 on filter change
|
||||||
evento: "",
|
loadTransactions();
|
||||||
servico: "",
|
}, 500);
|
||||||
nome: "",
|
return () => clearTimeout(timer);
|
||||||
status: "",
|
}, [filters]);
|
||||||
});
|
|
||||||
|
|
||||||
// Advanced date filters
|
// Advanced date filters
|
||||||
const [dateFilters, setDateFilters] = useState({
|
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>
|
<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>
|
<p className="text-gray-500 text-sm">Controle financeiro e transações</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex gap-2">
|
||||||
onClick={() => {
|
<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
|
setFormData({ // Clear form to initial state
|
||||||
fot: 0,
|
fot: 0,
|
||||||
data: new Date().toISOString().split("T")[0],
|
data: new Date().toISOString().split("T")[0],
|
||||||
|
|
@ -705,6 +753,7 @@ const Finance: React.FC = () => {
|
||||||
<Plus size={18}/> Nova Transação
|
<Plus size={18}/> Nova Transação
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Advanced Date Filters */}
|
{/* Advanced Date Filters */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -763,12 +812,50 @@ const Finance: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* List */}
|
||||||
<div className="bg-white rounded shadow overflow-x-auto">
|
<div className="bg-white rounded shadow flex flex-col border-t-0 rounded-t-none">
|
||||||
<table className="w-full text-xs text-left whitespace-nowrap">
|
{/* 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">
|
<thead className="bg-gray-100 border-b">
|
||||||
<tr>
|
<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">
|
<th key={h} className="px-3 py-2 font-semibold text-gray-700 align-top">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span>{h}</span>
|
<span>{h}</span>
|
||||||
|
|
@ -785,7 +872,27 @@ const Finance: React.FC = () => {
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y relative">
|
<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;
|
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
|
// 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 font-bold">{t.fot || "?"}</td>
|
||||||
<td className="px-3 py-2">{t.data}</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.tipoEvento}</td>
|
||||||
<td className="px-3 py-2">{t.tipoServico}</td>
|
<td className="px-3 py-2">{t.tipoServico}</td>
|
||||||
<td className="px-3 py-2">{t.nome}</td>
|
<td className="px-3 py-2">{t.nome}</td>
|
||||||
|
|
@ -852,6 +963,31 @@ const Finance: React.FC = () => {
|
||||||
{sortedTransactions.length === 0 && !loading && (
|
{sortedTransactions.length === 0 && !loading && (
|
||||||
<div className="p-8 text-center text-gray-500">Nenhuma transação encontrada.</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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 {
|
interface ImportFotInput {
|
||||||
fot: string;
|
fot: string;
|
||||||
|
|
@ -63,9 +63,30 @@ interface ImportProfissionalInput {
|
||||||
// Add other fields as needed
|
// 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 = () => {
|
export const ImportData: React.FC = () => {
|
||||||
const { token } = useAuth();
|
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)
|
// Generic data state (can be Fot, Agenda, Profissionais)
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
|
|
@ -132,11 +153,149 @@ export const ImportData: React.FC = () => {
|
||||||
const wsname = wb.SheetNames[0];
|
const wsname = wb.SheetNames[0];
|
||||||
const ws = wb.Sheets[wsname];
|
const ws = wb.Sheets[wsname];
|
||||||
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
const jsonData = XLSX.utils.sheet_to_json(ws, { header: 1 }) as any[][];
|
||||||
|
const rows = jsonData as any[];
|
||||||
|
|
||||||
let mappedData: any[] = [];
|
let mappedData: any[] = [];
|
||||||
let skipped = 0;
|
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++) {
|
for (let i = 1; i < jsonData.length; i++) {
|
||||||
const row = jsonData[i];
|
const row = jsonData[i];
|
||||||
if (!row || row.length === 0) continue;
|
if (!row || row.length === 0) continue;
|
||||||
|
|
@ -367,6 +526,7 @@ export const ImportData: React.FC = () => {
|
||||||
if (activeTab === 'fot') endpoint = '/api/import/fot';
|
if (activeTab === 'fot') endpoint = '/api/import/fot';
|
||||||
else if (activeTab === 'agenda') endpoint = '/api/import/agenda';
|
else if (activeTab === 'agenda') endpoint = '/api/import/agenda';
|
||||||
else if (activeTab === 'profissionais') endpoint = '/api/profissionais/import';
|
else if (activeTab === 'profissionais') endpoint = '/api/profissionais/import';
|
||||||
|
else if (activeTab === 'financeiro') endpoint = '/api/finance/import';
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -473,6 +633,17 @@ export const ImportData: React.FC = () => {
|
||||||
<UserPlus className="w-4 h-4" />
|
<UserPlus className="w-4 h-4" />
|
||||||
Profissionais
|
Profissionais
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -487,6 +658,9 @@ export const ImportData: React.FC = () => {
|
||||||
{activeTab === 'profissionais' && (
|
{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>
|
<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>
|
||||||
|
|
||||||
<div className="bg-white p-6 rounded-lg shadow space-y-4">
|
<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>
|
<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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<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>
|
<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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue