diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 8856c7d..b104eaa 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -16,7 +16,9 @@ import ( "photum-backend/internal/db" "photum-backend/internal/empresas" "photum-backend/internal/escalas" + "photum-backend/internal/finance" "photum-backend/internal/funcoes" + "photum-backend/internal/logistica" "photum-backend/internal/profissionais" "photum-backend/internal/storage" @@ -98,6 +100,7 @@ func main() { escalasHandler := escalas.NewHandler(escalas.NewService(queries)) logisticaHandler := logistica.NewHandler(logistica.NewService(queries)) codigosHandler := codigos.NewHandler(codigos.NewService(queries)) + financeHandler := finance.NewHandler(finance.NewService(queries)) r := gin.Default() @@ -240,6 +243,19 @@ func main() { codigosGroup.DELETE("/:id", codigosHandler.Delete) } + financeGroup := api.Group("/finance") + { + financeGroup.POST("", financeHandler.Create) + financeGroup.GET("", financeHandler.List) + financeGroup.GET("/autofill", financeHandler.AutoFill) + financeGroup.GET("/fot-events", financeHandler.GetFotEvents) + financeGroup.GET("/fot-search", financeHandler.SearchFot) + financeGroup.GET("/professionals", financeHandler.SearchProfessionals) + financeGroup.GET("/price", financeHandler.GetPrice) + financeGroup.PUT("/:id", financeHandler.Update) + financeGroup.DELETE("/:id", financeHandler.Delete) + } + admin := api.Group("/admin") { admin.GET("/users", authHandler.ListUsers) diff --git a/backend/docs/docs.go b/backend/docs/docs.go index d02967c..2504e51 100644 --- a/backend/docs/docs.go +++ b/backend/docs/docs.go @@ -3333,6 +3333,13 @@ const docTemplate = `{ "funcao_profissional_id": { "type": "string" }, + "funcoes_ids": { + "description": "New field", + "type": "array", + "items": { + "type": "string" + } + }, "media": { "type": "number" }, @@ -3415,12 +3422,15 @@ const docTemplate = `{ "type": "boolean" }, "funcao_profissional": { - "description": "Now returns name from join", + "description": "Deprecated single name (optional)", "type": "string" }, "funcao_profissional_id": { "type": "string" }, + "functions": { + "description": "New JSON array" + }, "id": { "type": "string" }, @@ -3507,6 +3517,13 @@ const docTemplate = `{ "funcao_profissional_id": { "type": "string" }, + "funcoes_ids": { + "description": "New field", + "type": "array", + "items": { + "type": "string" + } + }, "media": { "type": "number" }, diff --git a/backend/docs/swagger.json b/backend/docs/swagger.json index cc733da..76e7ea8 100644 --- a/backend/docs/swagger.json +++ b/backend/docs/swagger.json @@ -3327,6 +3327,13 @@ "funcao_profissional_id": { "type": "string" }, + "funcoes_ids": { + "description": "New field", + "type": "array", + "items": { + "type": "string" + } + }, "media": { "type": "number" }, @@ -3409,12 +3416,15 @@ "type": "boolean" }, "funcao_profissional": { - "description": "Now returns name from join", + "description": "Deprecated single name (optional)", "type": "string" }, "funcao_profissional_id": { "type": "string" }, + "functions": { + "description": "New JSON array" + }, "id": { "type": "string" }, @@ -3501,6 +3511,13 @@ "funcao_profissional_id": { "type": "string" }, + "funcoes_ids": { + "description": "New field", + "type": "array", + "items": { + "type": "string" + } + }, "media": { "type": "number" }, diff --git a/backend/docs/swagger.yaml b/backend/docs/swagger.yaml index e2c2760..a70a6aa 100644 --- a/backend/docs/swagger.yaml +++ b/backend/docs/swagger.yaml @@ -319,6 +319,11 @@ definitions: type: boolean funcao_profissional_id: type: string + funcoes_ids: + description: New field + items: + type: string + type: array media: type: number nome: @@ -374,10 +379,12 @@ definitions: extra_por_equipamento: type: boolean funcao_profissional: - description: Now returns name from join + description: Deprecated single name (optional) type: string funcao_profissional_id: type: string + functions: + description: New JSON array id: type: string media: @@ -435,6 +442,11 @@ definitions: type: boolean funcao_profissional_id: type: string + funcoes_ids: + description: New field + items: + type: string + type: array media: type: number nome: diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index de30a78..d8e8e09 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -242,7 +242,8 @@ func (h *Handler) Login(c *gin.Context) { "id": uuid.UUID(profData.ID.Bytes).String(), "nome": profData.Nome, "funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(), - "funcao_profissional": profData.FuncaoNome.String, + "funcao_profissional": "", // Deprecated/Removed from query + "functions": profData.Functions, "equipamentos": profData.Equipamentos.String, "avatar_url": profData.AvatarUrl.String, } @@ -369,7 +370,8 @@ func (h *Handler) Me(c *gin.Context) { "id": uuid.UUID(profData.ID.Bytes).String(), "nome": profData.Nome, "funcao_profissional_id": uuid.UUID(profData.FuncaoProfissionalID.Bytes).String(), - "funcao_profissional": profData.FuncaoNome.String, + "funcao_profissional": "", // Deprecated + "functions": profData.Functions, "equipamentos": profData.Equipamentos.String, "avatar_url": profData.AvatarUrl.String, } diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index 093f844..103c256 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -462,6 +462,98 @@ func (q *Queries) ListAgendas(ctx context.Context) ([]ListAgendasRow, error) { return items, nil } +const listAgendasByFot = `-- name: ListAgendasByFot :many +SELECT + a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, + te.nome as tipo_evento_nome +FROM agenda a +JOIN tipos_eventos te ON a.tipo_evento_id = te.id +WHERE a.fot_id = $1 +ORDER BY a.data_evento +` + +type ListAgendasByFotRow struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + FotID pgtype.UUID `json:"fot_id"` + DataEvento pgtype.Date `json:"data_evento"` + TipoEventoID pgtype.UUID `json:"tipo_evento_id"` + ObservacoesEvento pgtype.Text `json:"observacoes_evento"` + LocalEvento pgtype.Text `json:"local_evento"` + Endereco pgtype.Text `json:"endereco"` + Horario pgtype.Text `json:"horario"` + QtdFormandos pgtype.Int4 `json:"qtd_formandos"` + QtdFotografos pgtype.Int4 `json:"qtd_fotografos"` + QtdRecepcionistas pgtype.Int4 `json:"qtd_recepcionistas"` + QtdCinegrafistas pgtype.Int4 `json:"qtd_cinegrafistas"` + QtdEstudios pgtype.Int4 `json:"qtd_estudios"` + QtdPontoFoto pgtype.Int4 `json:"qtd_ponto_foto"` + QtdPontoID pgtype.Int4 `json:"qtd_ponto_id"` + QtdPontoDecorado pgtype.Int4 `json:"qtd_ponto_decorado"` + QtdPontosLed pgtype.Int4 `json:"qtd_pontos_led"` + QtdPlataforma360 pgtype.Int4 `json:"qtd_plataforma_360"` + StatusProfissionais pgtype.Text `json:"status_profissionais"` + FotoFaltante pgtype.Int4 `json:"foto_faltante"` + RecepFaltante pgtype.Int4 `json:"recep_faltante"` + CineFaltante pgtype.Int4 `json:"cine_faltante"` + LogisticaObservacoes pgtype.Text `json:"logistica_observacoes"` + PreVenda pgtype.Bool `json:"pre_venda"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Status pgtype.Text `json:"status"` + TipoEventoNome string `json:"tipo_evento_nome"` +} + +func (q *Queries) ListAgendasByFot(ctx context.Context, fotID pgtype.UUID) ([]ListAgendasByFotRow, error) { + rows, err := q.db.Query(ctx, listAgendasByFot, fotID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListAgendasByFotRow + for rows.Next() { + var i ListAgendasByFotRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.FotID, + &i.DataEvento, + &i.TipoEventoID, + &i.ObservacoesEvento, + &i.LocalEvento, + &i.Endereco, + &i.Horario, + &i.QtdFormandos, + &i.QtdFotografos, + &i.QtdRecepcionistas, + &i.QtdCinegrafistas, + &i.QtdEstudios, + &i.QtdPontoFoto, + &i.QtdPontoID, + &i.QtdPontoDecorado, + &i.QtdPontosLed, + &i.QtdPlataforma360, + &i.StatusProfissionais, + &i.FotoFaltante, + &i.RecepFaltante, + &i.CineFaltante, + &i.LogisticaObservacoes, + &i.PreVenda, + &i.CriadoEm, + &i.AtualizadoEm, + &i.Status, + &i.TipoEventoNome, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listAgendasByUser = `-- name: ListAgendasByUser :many SELECT a.id, a.user_id, a.fot_id, a.data_evento, a.tipo_evento_id, a.observacoes_evento, a.local_evento, a.endereco, a.horario, a.qtd_formandos, a.qtd_fotografos, a.qtd_recepcionistas, a.qtd_cinegrafistas, a.qtd_estudios, a.qtd_ponto_foto, a.qtd_ponto_id, a.qtd_ponto_decorado, a.qtd_pontos_led, a.qtd_plataforma_360, a.status_profissionais, a.foto_faltante, a.recep_faltante, a.cine_faltante, a.logistica_observacoes, a.pre_venda, a.criado_em, a.atualizado_em, a.status, diff --git a/backend/internal/db/generated/cadastro_fot.sql.go b/backend/internal/db/generated/cadastro_fot.sql.go index d720733..5ff98a4 100644 --- a/backend/internal/db/generated/cadastro_fot.sql.go +++ b/backend/internal/db/generated/cadastro_fot.sql.go @@ -98,6 +98,62 @@ func (q *Queries) GetCadastroFotByFOT(ctx context.Context, fot int32) (CadastroF return i, err } +const getCadastroFotByFotJoin = `-- name: GetCadastroFotByFotJoin :one +SELECT + c.id, c.fot, c.empresa_id, c.curso_id, c.ano_formatura_id, c.instituicao, c.cidade, c.estado, c.observacoes, c.gastos_captacao, c.pre_venda, c.created_at, c.updated_at, + e.nome as empresa_nome, + cur.nome as curso_nome, + a.ano_semestre as ano_formatura_label +FROM cadastro_fot c +JOIN empresas e ON c.empresa_id = e.id +JOIN cursos cur ON c.curso_id = cur.id +JOIN anos_formaturas a ON c.ano_formatura_id = a.id +WHERE c.fot = $1 +` + +type GetCadastroFotByFotJoinRow struct { + ID pgtype.UUID `json:"id"` + Fot int32 `json:"fot"` + EmpresaID pgtype.UUID `json:"empresa_id"` + CursoID pgtype.UUID `json:"curso_id"` + AnoFormaturaID pgtype.UUID `json:"ano_formatura_id"` + Instituicao pgtype.Text `json:"instituicao"` + Cidade pgtype.Text `json:"cidade"` + Estado pgtype.Text `json:"estado"` + Observacoes pgtype.Text `json:"observacoes"` + GastosCaptacao pgtype.Numeric `json:"gastos_captacao"` + PreVenda pgtype.Bool `json:"pre_venda"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EmpresaNome string `json:"empresa_nome"` + CursoNome string `json:"curso_nome"` + AnoFormaturaLabel string `json:"ano_formatura_label"` +} + +func (q *Queries) GetCadastroFotByFotJoin(ctx context.Context, fot int32) (GetCadastroFotByFotJoinRow, error) { + row := q.db.QueryRow(ctx, getCadastroFotByFotJoin, fot) + var i GetCadastroFotByFotJoinRow + err := row.Scan( + &i.ID, + &i.Fot, + &i.EmpresaID, + &i.CursoID, + &i.AnoFormaturaID, + &i.Instituicao, + &i.Cidade, + &i.Estado, + &i.Observacoes, + &i.GastosCaptacao, + &i.PreVenda, + &i.CreatedAt, + &i.UpdatedAt, + &i.EmpresaNome, + &i.CursoNome, + &i.AnoFormaturaLabel, + ) + return i, err +} + const getCadastroFotByID = `-- name: GetCadastroFotByID :one SELECT c.id, c.fot, c.empresa_id, c.curso_id, c.ano_formatura_id, c.instituicao, c.cidade, c.estado, c.observacoes, c.gastos_captacao, c.pre_venda, c.created_at, c.updated_at, @@ -293,6 +349,77 @@ func (q *Queries) ListCadastroFotByEmpresa(ctx context.Context, empresaID pgtype return items, nil } +const searchFot = `-- name: SearchFot :many +SELECT + c.id, c.fot, c.empresa_id, c.curso_id, c.ano_formatura_id, c.instituicao, c.cidade, c.estado, c.observacoes, c.gastos_captacao, c.pre_venda, c.created_at, c.updated_at, + e.nome as empresa_nome, + cur.nome as curso_nome, + a.ano_semestre as ano_formatura_label +FROM cadastro_fot c +JOIN empresas e ON c.empresa_id = e.id +JOIN cursos cur ON c.curso_id = cur.id +JOIN anos_formaturas a ON c.ano_formatura_id = a.id +WHERE CAST(c.fot AS TEXT) ILIKE '%' || $1 || '%' +ORDER BY c.fot ASC +LIMIT 10 +` + +type SearchFotRow struct { + ID pgtype.UUID `json:"id"` + Fot int32 `json:"fot"` + EmpresaID pgtype.UUID `json:"empresa_id"` + CursoID pgtype.UUID `json:"curso_id"` + AnoFormaturaID pgtype.UUID `json:"ano_formatura_id"` + Instituicao pgtype.Text `json:"instituicao"` + Cidade pgtype.Text `json:"cidade"` + Estado pgtype.Text `json:"estado"` + Observacoes pgtype.Text `json:"observacoes"` + GastosCaptacao pgtype.Numeric `json:"gastos_captacao"` + PreVenda pgtype.Bool `json:"pre_venda"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EmpresaNome string `json:"empresa_nome"` + CursoNome string `json:"curso_nome"` + AnoFormaturaLabel string `json:"ano_formatura_label"` +} + +func (q *Queries) SearchFot(ctx context.Context, dollar_1 pgtype.Text) ([]SearchFotRow, error) { + rows, err := q.db.Query(ctx, searchFot, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchFotRow + for rows.Next() { + var i SearchFotRow + if err := rows.Scan( + &i.ID, + &i.Fot, + &i.EmpresaID, + &i.CursoID, + &i.AnoFormaturaID, + &i.Instituicao, + &i.Cidade, + &i.Estado, + &i.Observacoes, + &i.GastosCaptacao, + &i.PreVenda, + &i.CreatedAt, + &i.UpdatedAt, + &i.EmpresaNome, + &i.CursoNome, + &i.AnoFormaturaLabel, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateCadastroFot = `-- name: UpdateCadastroFot :one UPDATE cadastro_fot SET fot = $2, @@ -356,3 +483,20 @@ func (q *Queries) UpdateCadastroFot(ctx context.Context, arg UpdateCadastroFotPa ) return i, err } + +const updateCadastroFotGastos = `-- name: UpdateCadastroFotGastos :exec +UPDATE cadastro_fot SET + gastos_captacao = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1 +` + +type UpdateCadastroFotGastosParams struct { + ID pgtype.UUID `json:"id"` + GastosCaptacao pgtype.Numeric `json:"gastos_captacao"` +} + +func (q *Queries) UpdateCadastroFotGastos(ctx context.Context, arg UpdateCadastroFotGastosParams) error { + _, err := q.db.Exec(ctx, updateCadastroFotGastos, arg.ID, arg.GastosCaptacao) + return err +} diff --git a/backend/internal/db/generated/financial_transactions.sql.go b/backend/internal/db/generated/financial_transactions.sql.go new file mode 100644 index 0000000..9190ba2 --- /dev/null +++ b/backend/internal/db/generated/financial_transactions.sql.go @@ -0,0 +1,311 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: financial_transactions.sql + +package generated + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +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 +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 +) 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 +` + +type CreateTransactionParams struct { + 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"` +} + +func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionParams) (FinancialTransaction, error) { + row := q.db.QueryRow(ctx, createTransaction, + arg.FotID, + arg.DataCobranca, + arg.TipoEvento, + arg.TipoServico, + arg.ProfessionalName, + arg.Whatsapp, + arg.Cpf, + arg.TabelaFree, + arg.ValorFree, + arg.ValorExtra, + arg.DescricaoExtra, + arg.TotalPagar, + arg.DataPagamento, + arg.PgtoOk, + ) + var i FinancialTransaction + err := row.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, + ) + return i, err +} + +const deleteTransaction = `-- name: DeleteTransaction :exec +DELETE FROM financial_transactions WHERE id = $1 +` + +func (q *Queries) DeleteTransaction(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteTransaction, id) + return err +} + +const getTransaction = `-- name: GetTransaction :one +SELECT 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 FROM financial_transactions WHERE id = $1 +` + +func (q *Queries) GetTransaction(ctx context.Context, id pgtype.UUID) (FinancialTransaction, error) { + row := q.db.QueryRow(ctx, getTransaction, id) + var i FinancialTransaction + err := row.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, + ) + return i, err +} + +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, 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 +` + +type ListTransactionsRow 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"` + FotNumero pgtype.Int4 `json:"fot_numero"` +} + +func (q *Queries) ListTransactions(ctx context.Context) ([]ListTransactionsRow, error) { + rows, err := q.db.Query(ctx, listTransactions) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListTransactionsRow + for rows.Next() { + var i ListTransactionsRow + 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.FotNumero, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listTransactionsByFot = `-- name: ListTransactionsByFot :many +SELECT 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 FROM financial_transactions +WHERE fot_id = $1 +ORDER BY data_cobranca DESC +` + +func (q *Queries) ListTransactionsByFot(ctx context.Context, fotID pgtype.UUID) ([]FinancialTransaction, error) { + rows, err := q.db.Query(ctx, listTransactionsByFot, fotID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []FinancialTransaction + for rows.Next() { + var i FinancialTransaction + 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, + ); 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 +WHERE fot_id = $1 +` + +func (q *Queries) SumTotalByFot(ctx context.Context, fotID pgtype.UUID) (pgtype.Numeric, error) { + row := q.db.QueryRow(ctx, sumTotalByFot, fotID) + var column_1 pgtype.Numeric + err := row.Scan(&column_1) + return column_1, err +} + +const updateTransaction = `-- name: UpdateTransaction :one +UPDATE financial_transactions SET + fot_id = $2, data_cobranca = $3, tipo_evento = $4, tipo_servico = $5, + professional_name = $6, whatsapp = $7, cpf = $8, tabela_free = $9, + valor_free = $10, valor_extra = $11, descricao_extra = $12, + total_pagar = $13, data_pagamento = $14, pgto_ok = $15, + atualizado_em = NOW() +WHERE id = $1 +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 +` + +type UpdateTransactionParams 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"` +} + +func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionParams) (FinancialTransaction, error) { + row := q.db.QueryRow(ctx, updateTransaction, + arg.ID, + arg.FotID, + arg.DataCobranca, + arg.TipoEvento, + arg.TipoServico, + arg.ProfessionalName, + arg.Whatsapp, + arg.Cpf, + arg.TabelaFree, + arg.ValorFree, + arg.ValorExtra, + arg.DescricaoExtra, + arg.TotalPagar, + arg.DataPagamento, + arg.PgtoOk, + ) + var i FinancialTransaction + err := row.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, + ) + return i, err +} diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index 7964435..e86a8e5 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -155,6 +155,26 @@ type Empresa struct { CriadoEm pgtype.Timestamptz `json:"criado_em"` } +type FinancialTransaction 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"` +} + type FuncoesProfissionai struct { ID pgtype.UUID `json:"id"` Nome string `json:"nome"` @@ -206,6 +226,11 @@ type PrecosTiposEvento struct { CriadoEm pgtype.Timestamptz `json:"criado_em"` } +type ProfissionaisFuncoesJunction struct { + ProfissionalID pgtype.UUID `json:"profissional_id"` + FuncaoID pgtype.UUID `json:"funcao_id"` +} + type RefreshToken struct { ID pgtype.UUID `json:"id"` UsuarioID pgtype.UUID `json:"usuario_id"` diff --git a/backend/internal/db/generated/profissionais.sql.go b/backend/internal/db/generated/profissionais.sql.go index 99a9cb7..f7277e9 100644 --- a/backend/internal/db/generated/profissionais.sql.go +++ b/backend/internal/db/generated/profissionais.sql.go @@ -11,6 +11,31 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const addFunctionToProfessional = `-- name: AddFunctionToProfessional :exec +INSERT INTO profissionais_funcoes_junction (profissional_id, funcao_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING +` + +type AddFunctionToProfessionalParams struct { + ProfissionalID pgtype.UUID `json:"profissional_id"` + FuncaoID pgtype.UUID `json:"funcao_id"` +} + +func (q *Queries) AddFunctionToProfessional(ctx context.Context, arg AddFunctionToProfessionalParams) error { + _, err := q.db.Exec(ctx, addFunctionToProfessional, arg.ProfissionalID, arg.FuncaoID) + return err +} + +const clearProfessionalFunctions = `-- name: ClearProfessionalFunctions :exec +DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1 +` + +func (q *Queries) ClearProfessionalFunctions(ctx context.Context, profissionalID pgtype.UUID) error { + _, err := q.db.Exec(ctx, clearProfessionalFunctions, profissionalID) + return err +} + const createProfissional = `-- name: CreateProfissional :one INSERT INTO cadastro_profissionais ( usuario_id, nome, funcao_profissional_id, endereco, cidade, uf, whatsapp, @@ -117,6 +142,15 @@ func (q *Queries) CreateProfissional(ctx context.Context, arg CreateProfissional return i, err } +const deleteProfessionalFunctions = `-- name: DeleteProfessionalFunctions :exec +DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1 +` + +func (q *Queries) DeleteProfessionalFunctions(ctx context.Context, profissionalID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteProfessionalFunctions, profissionalID) + return err +} + const deleteProfissional = `-- name: DeleteProfissional :exec DELETE FROM cadastro_profissionais WHERE id = $1 @@ -128,9 +162,15 @@ func (q *Queries) DeleteProfissional(ctx context.Context, id pgtype.UUID) error } const getProfissionalByID = `-- name: GetProfissionalByID :one -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.id = $1 LIMIT 1 ` @@ -164,7 +204,7 @@ type GetProfissionalByIDRow struct { AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` - FuncaoNome pgtype.Text `json:"funcao_nome"` + Functions interface{} `json:"functions"` } func (q *Queries) GetProfissionalByID(ctx context.Context, id pgtype.UUID) (GetProfissionalByIDRow, error) { @@ -200,15 +240,21 @@ func (q *Queries) GetProfissionalByID(ctx context.Context, id pgtype.UUID) (GetP &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, - &i.FuncaoNome, + &i.Functions, ) return i, err } const getProfissionalByUsuarioID = `-- name: GetProfissionalByUsuarioID :one -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.usuario_id = $1 LIMIT 1 ` @@ -242,7 +288,7 @@ type GetProfissionalByUsuarioIDRow struct { AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` - FuncaoNome pgtype.Text `json:"funcao_nome"` + Functions interface{} `json:"functions"` } func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgtype.UUID) (GetProfissionalByUsuarioIDRow, error) { @@ -278,15 +324,21 @@ func (q *Queries) GetProfissionalByUsuarioID(ctx context.Context, usuarioID pgty &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, - &i.FuncaoNome, + &i.Functions, ) return i, err } const listProfissionais = `-- name: ListProfissionais :many -SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, f.nome as funcao_nome, u.email as usuario_email +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, u.email as usuario_email, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id LEFT JOIN usuarios u ON p.usuario_id = u.id ORDER BY p.nome ` @@ -321,8 +373,8 @@ type ListProfissionaisRow struct { AvatarUrl pgtype.Text `json:"avatar_url"` CriadoEm pgtype.Timestamptz `json:"criado_em"` AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` - FuncaoNome pgtype.Text `json:"funcao_nome"` UsuarioEmail pgtype.Text `json:"usuario_email"` + Functions interface{} `json:"functions"` } func (q *Queries) ListProfissionais(ctx context.Context) ([]ListProfissionaisRow, error) { @@ -364,8 +416,221 @@ func (q *Queries) ListProfissionais(ctx context.Context) ([]ListProfissionaisRow &i.AvatarUrl, &i.CriadoEm, &i.AtualizadoEm, - &i.FuncaoNome, &i.UsuarioEmail, + &i.Functions, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchProfissionais = `-- name: SearchProfissionais :many +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions +FROM cadastro_profissionais p +WHERE p.nome ILIKE '%' || $1 || '%' +ORDER BY p.nome +LIMIT 20 +` + +type SearchProfissionaisRow struct { + ID pgtype.UUID `json:"id"` + UsuarioID pgtype.UUID `json:"usuario_id"` + Nome string `json:"nome"` + FuncaoProfissionalID pgtype.UUID `json:"funcao_profissional_id"` + Endereco pgtype.Text `json:"endereco"` + Cidade pgtype.Text `json:"cidade"` + Uf pgtype.Text `json:"uf"` + Whatsapp pgtype.Text `json:"whatsapp"` + CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"` + Banco pgtype.Text `json:"banco"` + Agencia pgtype.Text `json:"agencia"` + ContaPix pgtype.Text `json:"conta_pix"` + CarroDisponivel pgtype.Bool `json:"carro_disponivel"` + TemEstudio pgtype.Bool `json:"tem_estudio"` + QtdEstudio pgtype.Int4 `json:"qtd_estudio"` + TipoCartao pgtype.Text `json:"tipo_cartao"` + Observacao pgtype.Text `json:"observacao"` + QualTec pgtype.Int4 `json:"qual_tec"` + EducacaoSimpatia pgtype.Int4 `json:"educacao_simpatia"` + DesempenhoEvento pgtype.Int4 `json:"desempenho_evento"` + DispHorario pgtype.Int4 `json:"disp_horario"` + Media pgtype.Numeric `json:"media"` + TabelaFree pgtype.Text `json:"tabela_free"` + ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` + Equipamentos pgtype.Text `json:"equipamentos"` + Email pgtype.Text `json:"email"` + AvatarUrl pgtype.Text `json:"avatar_url"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Functions interface{} `json:"functions"` +} + +func (q *Queries) SearchProfissionais(ctx context.Context, dollar_1 pgtype.Text) ([]SearchProfissionaisRow, error) { + rows, err := q.db.Query(ctx, searchProfissionais, dollar_1) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchProfissionaisRow + for rows.Next() { + var i SearchProfissionaisRow + if err := rows.Scan( + &i.ID, + &i.UsuarioID, + &i.Nome, + &i.FuncaoProfissionalID, + &i.Endereco, + &i.Cidade, + &i.Uf, + &i.Whatsapp, + &i.CpfCnpjTitular, + &i.Banco, + &i.Agencia, + &i.ContaPix, + &i.CarroDisponivel, + &i.TemEstudio, + &i.QtdEstudio, + &i.TipoCartao, + &i.Observacao, + &i.QualTec, + &i.EducacaoSimpatia, + &i.DesempenhoEvento, + &i.DispHorario, + &i.Media, + &i.TabelaFree, + &i.ExtraPorEquipamento, + &i.Equipamentos, + &i.Email, + &i.AvatarUrl, + &i.CriadoEm, + &i.AtualizadoEm, + &i.Functions, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchProfissionaisByFunction = `-- name: SearchProfissionaisByFunction :many +SELECT p.id, p.usuario_id, p.nome, p.funcao_profissional_id, p.endereco, p.cidade, p.uf, p.whatsapp, p.cpf_cnpj_titular, p.banco, p.agencia, p.conta_pix, p.carro_disponivel, p.tem_estudio, p.qtd_estudio, p.tipo_cartao, p.observacao, p.qual_tec, p.educacao_simpatia, p.desempenho_evento, p.disp_horario, p.media, p.tabela_free, p.extra_por_equipamento, p.equipamentos, p.email, p.avatar_url, p.criado_em, p.atualizado_em, + COALESCE( + (SELECT json_agg(json_build_object('id', f2.id, 'nome', f2.nome)) + FROM profissionais_funcoes_junction pfj2 + JOIN funcoes_profissionais f2 ON pfj2.funcao_id = f2.id + WHERE pfj2.profissional_id = p.id + ), '[]'::json + ) as functions +FROM cadastro_profissionais p +WHERE (p.nome ILIKE '%' || $1 || '%') + AND ( + EXISTS ( + SELECT 1 + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id AND f.nome = $2 + ) + OR + p.funcao_profissional_id = (SELECT id FROM funcoes_profissionais WHERE nome = $2 LIMIT 1) + ) +ORDER BY p.nome +LIMIT 20 +` + +type SearchProfissionaisByFunctionParams struct { + Column1 pgtype.Text `json:"column_1"` + Nome string `json:"nome"` +} + +type SearchProfissionaisByFunctionRow struct { + ID pgtype.UUID `json:"id"` + UsuarioID pgtype.UUID `json:"usuario_id"` + Nome string `json:"nome"` + FuncaoProfissionalID pgtype.UUID `json:"funcao_profissional_id"` + Endereco pgtype.Text `json:"endereco"` + Cidade pgtype.Text `json:"cidade"` + Uf pgtype.Text `json:"uf"` + Whatsapp pgtype.Text `json:"whatsapp"` + CpfCnpjTitular pgtype.Text `json:"cpf_cnpj_titular"` + Banco pgtype.Text `json:"banco"` + Agencia pgtype.Text `json:"agencia"` + ContaPix pgtype.Text `json:"conta_pix"` + CarroDisponivel pgtype.Bool `json:"carro_disponivel"` + TemEstudio pgtype.Bool `json:"tem_estudio"` + QtdEstudio pgtype.Int4 `json:"qtd_estudio"` + TipoCartao pgtype.Text `json:"tipo_cartao"` + Observacao pgtype.Text `json:"observacao"` + QualTec pgtype.Int4 `json:"qual_tec"` + EducacaoSimpatia pgtype.Int4 `json:"educacao_simpatia"` + DesempenhoEvento pgtype.Int4 `json:"desempenho_evento"` + DispHorario pgtype.Int4 `json:"disp_horario"` + Media pgtype.Numeric `json:"media"` + TabelaFree pgtype.Text `json:"tabela_free"` + ExtraPorEquipamento pgtype.Bool `json:"extra_por_equipamento"` + Equipamentos pgtype.Text `json:"equipamentos"` + Email pgtype.Text `json:"email"` + AvatarUrl pgtype.Text `json:"avatar_url"` + CriadoEm pgtype.Timestamptz `json:"criado_em"` + AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"` + Functions interface{} `json:"functions"` +} + +func (q *Queries) SearchProfissionaisByFunction(ctx context.Context, arg SearchProfissionaisByFunctionParams) ([]SearchProfissionaisByFunctionRow, error) { + rows, err := q.db.Query(ctx, searchProfissionaisByFunction, arg.Column1, arg.Nome) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchProfissionaisByFunctionRow + for rows.Next() { + var i SearchProfissionaisByFunctionRow + if err := rows.Scan( + &i.ID, + &i.UsuarioID, + &i.Nome, + &i.FuncaoProfissionalID, + &i.Endereco, + &i.Cidade, + &i.Uf, + &i.Whatsapp, + &i.CpfCnpjTitular, + &i.Banco, + &i.Agencia, + &i.ContaPix, + &i.CarroDisponivel, + &i.TemEstudio, + &i.QtdEstudio, + &i.TipoCartao, + &i.Observacao, + &i.QualTec, + &i.EducacaoSimpatia, + &i.DesempenhoEvento, + &i.DispHorario, + &i.Media, + &i.TabelaFree, + &i.ExtraPorEquipamento, + &i.Equipamentos, + &i.Email, + &i.AvatarUrl, + &i.CriadoEm, + &i.AtualizadoEm, + &i.Functions, ); err != nil { return nil, err } diff --git a/backend/internal/db/generated/tipos_eventos.sql.go b/backend/internal/db/generated/tipos_eventos.sql.go index dd94b91..47619c0 100644 --- a/backend/internal/db/generated/tipos_eventos.sql.go +++ b/backend/internal/db/generated/tipos_eventos.sql.go @@ -89,6 +89,27 @@ func (q *Queries) GetPreco(ctx context.Context, arg GetPrecoParams) (PrecosTipos return i, err } +const getStandardPrice = `-- name: GetStandardPrice :one +SELECT p.valor +FROM precos_tipos_eventos p +JOIN tipos_eventos te ON p.tipo_evento_id = te.id +JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id +WHERE te.nome = $1 AND f.nome = $2 +LIMIT 1 +` + +type GetStandardPriceParams struct { + Nome string `json:"nome"` + Nome_2 string `json:"nome_2"` +} + +func (q *Queries) GetStandardPrice(ctx context.Context, arg GetStandardPriceParams) (pgtype.Numeric, error) { + row := q.db.QueryRow(ctx, getStandardPrice, arg.Nome, arg.Nome_2) + var valor pgtype.Numeric + err := row.Scan(&valor) + return valor, err +} + const getTipoEventoByID = `-- name: GetTipoEventoByID :one SELECT id, nome, criado_em FROM tipos_eventos WHERE id = $1 ` diff --git a/backend/internal/db/migrations/002_multi_function.sql b/backend/internal/db/migrations/002_multi_function.sql new file mode 100644 index 0000000..7818d95 --- /dev/null +++ b/backend/internal/db/migrations/002_multi_function.sql @@ -0,0 +1,18 @@ +-- Migration to support multiple functions per professional + +-- 1. Create Junction Table +CREATE TABLE IF NOT EXISTS profissionais_funcoes_junction ( + profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE, + funcao_id UUID NOT NULL REFERENCES funcoes_profissionais(id) ON DELETE CASCADE, + PRIMARY KEY (profissional_id, funcao_id) +); + +-- 2. Migrate existing data (assuming column exists) +INSERT INTO profissionais_funcoes_junction (profissional_id, funcao_id) +SELECT id, funcao_profissional_id +FROM cadastro_profissionais +WHERE funcao_profissional_id IS NOT NULL +ON CONFLICT DO NOTHING; + +-- 3. (Optional) Drop the old column later. keeping it for backward compat for a moment, or handle it in Go. +-- ALTER TABLE cadastro_profissionais DROP COLUMN funcao_profissional_id; diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index 14627bc..a837383 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -188,4 +188,13 @@ JOIN agenda a ON ap.agenda_id = a.id WHERE ap.profissional_id = $1 AND a.data_evento = $2 AND ap.status = 'ACEITO' - AND a.id != $3; \ No newline at end of file + AND a.id != $3; + +-- name: ListAgendasByFot :many +SELECT + a.*, + te.nome as tipo_evento_nome +FROM agenda a +JOIN tipos_eventos te ON a.tipo_evento_id = te.id +WHERE a.fot_id = $1 +ORDER BY a.data_evento; \ No newline at end of file diff --git a/backend/internal/db/queries/cadastro_fot.sql b/backend/internal/db/queries/cadastro_fot.sql index 4d923fa..e0c7010 100644 --- a/backend/internal/db/queries/cadastro_fot.sql +++ b/backend/internal/db/queries/cadastro_fot.sql @@ -63,3 +63,37 @@ RETURNING *; -- name: DeleteCadastroFot :exec DELETE FROM cadastro_fot WHERE id = $1; + +-- name: GetCadastroFotByFotJoin :one +SELECT + c.*, + e.nome as empresa_nome, + cur.nome as curso_nome, + a.ano_semestre as ano_formatura_label +FROM cadastro_fot c +JOIN empresas e ON c.empresa_id = e.id +JOIN cursos cur ON c.curso_id = cur.id +JOIN anos_formaturas a ON c.ano_formatura_id = a.id +WHERE c.fot = $1; + +-- name: UpdateCadastroFotGastos :exec +UPDATE cadastro_fot SET + gastos_captacao = $2, + updated_at = CURRENT_TIMESTAMP +WHERE id = $1; + +-- name: SearchFot :many +SELECT + c.*, + e.nome as empresa_nome, + cur.nome as curso_nome, + a.ano_semestre as ano_formatura_label +FROM cadastro_fot c +JOIN empresas e ON c.empresa_id = e.id +JOIN cursos cur ON c.curso_id = cur.id +JOIN anos_formaturas a ON c.ano_formatura_id = a.id +WHERE CAST(c.fot AS TEXT) ILIKE '%' || $1 || '%' +ORDER BY c.fot ASC +LIMIT 10; + + diff --git a/backend/internal/db/queries/financial_transactions.sql b/backend/internal/db/queries/financial_transactions.sql new file mode 100644 index 0000000..3ddb56f --- /dev/null +++ b/backend/internal/db/queries/financial_transactions.sql @@ -0,0 +1,41 @@ +-- 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 +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 +) RETURNING *; + +-- name: ListTransactionsByFot :many +SELECT * FROM financial_transactions +WHERE fot_id = $1 +ORDER BY data_cobranca DESC; + +-- name: ListTransactions :many +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; + +-- name: SumTotalByFot :one +SELECT COALESCE(SUM(total_pagar), 0)::NUMERIC +FROM financial_transactions +WHERE fot_id = $1; + +-- name: UpdateTransaction :one +UPDATE financial_transactions SET + fot_id = $2, data_cobranca = $3, tipo_evento = $4, tipo_servico = $5, + professional_name = $6, whatsapp = $7, cpf = $8, tabela_free = $9, + valor_free = $10, valor_extra = $11, descricao_extra = $12, + total_pagar = $13, data_pagamento = $14, pgto_ok = $15, + atualizado_em = NOW() +WHERE id = $1 +RETURNING *; + +-- name: DeleteTransaction :exec +DELETE FROM financial_transactions WHERE id = $1; + +-- name: GetTransaction :one +SELECT * FROM financial_transactions WHERE id = $1; + diff --git a/backend/internal/db/queries/profissionais.sql b/backend/internal/db/queries/profissionais.sql index 66282ff..7a76eb8 100644 --- a/backend/internal/db/queries/profissionais.sql +++ b/backend/internal/db/queries/profissionais.sql @@ -11,21 +11,39 @@ INSERT INTO cadastro_profissionais ( ) RETURNING *; -- name: GetProfissionalByUsuarioID :one -SELECT p.*, f.nome as funcao_nome +SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.usuario_id = $1 LIMIT 1; -- name: GetProfissionalByID :one -SELECT p.*, f.nome as funcao_nome +SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id WHERE p.id = $1 LIMIT 1; -- name: ListProfissionais :many -SELECT p.*, f.nome as funcao_nome, u.email as usuario_email +SELECT p.*, u.email as usuario_email, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions FROM cadastro_profissionais p -LEFT JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id LEFT JOIN usuarios u ON p.usuario_id = u.id ORDER BY p.nome; @@ -64,3 +82,52 @@ RETURNING *; -- name: DeleteProfissional :exec DELETE FROM cadastro_profissionais WHERE id = $1; + +-- name: SearchProfissionais :many +SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object('id', f.id, 'nome', f.nome)) + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id + ), '[]'::json + ) as functions +FROM cadastro_profissionais p +WHERE p.nome ILIKE '%' || $1 || '%' +ORDER BY p.nome +LIMIT 20; + +-- name: SearchProfissionaisByFunction :many +SELECT p.*, + COALESCE( + (SELECT json_agg(json_build_object('id', f2.id, 'nome', f2.nome)) + FROM profissionais_funcoes_junction pfj2 + JOIN funcoes_profissionais f2 ON pfj2.funcao_id = f2.id + WHERE pfj2.profissional_id = p.id + ), '[]'::json + ) as functions +FROM cadastro_profissionais p +WHERE (p.nome ILIKE '%' || $1 || '%') + AND ( + EXISTS ( + SELECT 1 + FROM profissionais_funcoes_junction pfj + JOIN funcoes_profissionais f ON pfj.funcao_id = f.id + WHERE pfj.profissional_id = p.id AND f.nome = $2 + ) + OR + p.funcao_profissional_id = (SELECT id FROM funcoes_profissionais WHERE nome = $2 LIMIT 1) + ) +ORDER BY p.nome +LIMIT 20; + +-- name: AddFunctionToProfessional :exec +INSERT INTO profissionais_funcoes_junction (profissional_id, funcao_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING; + +-- name: ClearProfessionalFunctions :exec +DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1; + +-- name: DeleteProfessionalFunctions :exec +DELETE FROM profissionais_funcoes_junction WHERE profissional_id = $1; diff --git a/backend/internal/db/queries/tipos_eventos.sql b/backend/internal/db/queries/tipos_eventos.sql index 4c161bd..5f0fcdd 100644 --- a/backend/internal/db/queries/tipos_eventos.sql +++ b/backend/internal/db/queries/tipos_eventos.sql @@ -31,3 +31,11 @@ SELECT * FROM precos_tipos_eventos WHERE tipo_evento_id = $1 AND funcao_profissi -- name: DeletePreco :exec DELETE FROM precos_tipos_eventos WHERE id = $1; + +-- name: GetStandardPrice :one +SELECT p.valor +FROM precos_tipos_eventos p +JOIN tipos_eventos te ON p.tipo_evento_id = te.id +JOIN funcoes_profissionais f ON p.funcao_profissional_id = f.id +WHERE te.nome = $1 AND f.nome = $2 +LIMIT 1; diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index 2a3f3e7..81d3d43 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -58,6 +58,12 @@ CREATE TABLE IF NOT EXISTS cadastro_profissionais ( atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS profissionais_funcoes_junction ( + profissional_id UUID NOT NULL REFERENCES cadastro_profissionais(id) ON DELETE CASCADE, + funcao_id UUID NOT NULL REFERENCES funcoes_profissionais(id) ON DELETE CASCADE, + PRIMARY KEY (profissional_id, funcao_id) +); + CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), usuario_id UUID NOT NULL REFERENCES usuarios(id) ON DELETE CASCADE, @@ -433,3 +439,25 @@ CREATE TABLE IF NOT EXISTS codigos_acesso ( ativo BOOLEAN NOT NULL DEFAULT TRUE, usos INT NOT NULL DEFAULT 0 ); + +-- Financeiro Extrato +CREATE TABLE IF NOT EXISTS financial_transactions ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + fot_id UUID REFERENCES cadastro_fot(id) ON DELETE SET NULL, + data_cobranca DATE, + tipo_evento VARCHAR(100), + tipo_servico VARCHAR(100), + professional_name VARCHAR(255), + whatsapp VARCHAR(50), + cpf VARCHAR(20), + tabela_free VARCHAR(50), + valor_free NUMERIC(10,2) DEFAULT 0, + valor_extra NUMERIC(10,2) DEFAULT 0, + descricao_extra TEXT, + total_pagar NUMERIC(10,2) DEFAULT 0, + data_pagamento DATE, + pgto_ok BOOLEAN DEFAULT FALSE, + criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(), + atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + diff --git a/backend/internal/finance/handler.go b/backend/internal/finance/handler.go new file mode 100644 index 0000000..ccd9b00 --- /dev/null +++ b/backend/internal/finance/handler.go @@ -0,0 +1,295 @@ +package finance + +import ( + "fmt" + "net/http" + "photum-backend/internal/db/generated" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgtype" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// Request DTO +type TransactionRequest struct { + FotID *string `json:"fot_id"` + DataCobranca string `json:"data_cobranca"` // YYYY-MM-DD + TipoEvento string `json:"tipo_evento"` + TipoServico string `json:"tipo_servico"` + ProfessionalName string `json:"professional_name"` + 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"` + DataPagamento *string `json:"data_pagamento"` + PgtoOk bool `json:"pgto_ok"` +} + +// Helper: Parse Date String to pgtype.Date +func parseDate(dateStr string) pgtype.Date { + if dateStr == "" { + return pgtype.Date{Valid: false} + } + t, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return pgtype.Date{Valid: false} + } + return pgtype.Date{Time: t, Valid: true} +} + +// Helper: Float to Numeric +func floatToNumeric(f float64) pgtype.Numeric { + s := fmt.Sprintf("%.2f", f) + var n pgtype.Numeric + n.Scan(s) + return n +} + +// Helper: String UUID to pgtype.UUID +func parseUUID(uuidStr *string) pgtype.UUID { + if uuidStr == nil || *uuidStr == "" { + return pgtype.UUID{Valid: false} + } + var u pgtype.UUID + err := u.Scan(*uuidStr) + if err != nil { + return pgtype.UUID{Valid: false} + } + return u +} + +// Helper: String to pgtype.Text +func toText(s string) pgtype.Text { + return pgtype.Text{String: s, Valid: s != ""} +} + +func (h *Handler) Create(c *gin.Context) { + var req TransactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + params := generated.CreateTransactionParams{ + FotID: parseUUID(req.FotID), + DataCobranca: parseDate(req.DataCobranca), + TipoEvento: toText(req.TipoEvento), + TipoServico: toText(req.TipoServico), + ProfessionalName: toText(req.ProfessionalName), + Whatsapp: toText(req.Whatsapp), + Cpf: toText(req.Cpf), + TabelaFree: toText(req.TabelaFree), + ValorFree: floatToNumeric(req.ValorFree), + ValorExtra: floatToNumeric(req.ValorExtra), + DescricaoExtra: toText(req.DescricaoExtra), + TotalPagar: floatToNumeric(req.ValorFree + req.ValorExtra), + PgtoOk: pgtype.Bool{Bool: req.PgtoOk, Valid: true}, + } + + if req.DataPagamento != nil { + params.DataPagamento = parseDate(*req.DataPagamento) + } + + txn, err := h.service.Create(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusCreated, txn) +} + +func (h *Handler) Update(c *gin.Context) { + idStr := c.Param("id") + var idUUID pgtype.UUID + if err := idUUID.Scan(idStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) + return + } + + var req TransactionRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + params := generated.UpdateTransactionParams{ + ID: idUUID, + FotID: parseUUID(req.FotID), + DataCobranca: parseDate(req.DataCobranca), + TipoEvento: toText(req.TipoEvento), + TipoServico: toText(req.TipoServico), + ProfessionalName: toText(req.ProfessionalName), + Whatsapp: toText(req.Whatsapp), + Cpf: toText(req.Cpf), + TabelaFree: toText(req.TabelaFree), + ValorFree: floatToNumeric(req.ValorFree), + ValorExtra: floatToNumeric(req.ValorExtra), + DescricaoExtra: toText(req.DescricaoExtra), + TotalPagar: floatToNumeric(req.ValorFree + req.ValorExtra), + PgtoOk: pgtype.Bool{Bool: req.PgtoOk, Valid: true}, + } + + if req.DataPagamento != nil { + params.DataPagamento = parseDate(*req.DataPagamento) + } + + txn, err := h.service.Update(c.Request.Context(), params) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, txn) +} + +func (h *Handler) Delete(c *gin.Context) { + idStr := c.Param("id") + var idUUID pgtype.UUID + if err := idUUID.Scan(idStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID"}) + return + } + + if err := h.service.Delete(c.Request.Context(), idUUID); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "deleted"}) +} + +func (h *Handler) List(c *gin.Context) { + fotIDStr := c.Query("fot_id") + if fotIDStr != "" { + var fotUUID pgtype.UUID + if err := fotUUID.Scan(fotIDStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid Fot ID"}) + return + } + list, err := h.service.ListByFot(c.Request.Context(), fotUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, list) + return + } + + list, err := h.service.ListAll(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, list) +} + +func (h *Handler) AutoFill(c *gin.Context) { + fotNumStr := c.Query("fot") + fotNum, err := strconv.Atoi(fotNumStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid FOT Number"}) + return + } + + fotData, err := h.service.AutoFillSearch(c.Request.Context(), int32(fotNum)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "FOT not found"}) + return + } + + c.JSON(http.StatusOK, fotData) +} + +func (h *Handler) GetFotEvents(c *gin.Context) { + fotNumStr := c.Query("fot_id") // Accepts UUID string currently, or we can look up by number if needed. + // User has UUID from AutoFill + var fotUUID pgtype.UUID + if err := fotUUID.Scan(fotNumStr); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid FOT ID"}) + return + } + + events, err := h.service.ListFotEvents(c.Request.Context(), fotUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, events) +} + +func (h *Handler) SearchProfessionals(c *gin.Context) { + query := c.Query("q") // can be empty if function is provided potentially? No, search usually needs text. + functionName := c.Query("function") + + // If function is provided, we might want to list all if query is empty? + // For now let's enforce query length if function is missing, or allow empty query if function is present. + + if query == "" && functionName == "" { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + if functionName != "" { + pros, err := h.service.SearchProfessionalsByFunction(c.Request.Context(), query, functionName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, pros) + return + } + + pros, err := h.service.SearchProfessionals(c.Request.Context(), query) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, pros) +} + +func (h *Handler) GetPrice(c *gin.Context) { + eventName := c.Query("event") + serviceName := c.Query("service") + + if eventName == "" || serviceName == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Missing event or service parameters"}) + return + } + + price, err := h.service.GetStandardPrice(c.Request.Context(), eventName, serviceName) + if err != nil { + // likely not found, return 0 or 404 + c.JSON(http.StatusOK, gin.H{"valor": 0}) + return + } + + val, _ := price.Float64Value() + c.JSON(http.StatusOK, gin.H{"valor": val.Float64}) +} + +func (h *Handler) SearchFot(c *gin.Context) { + query := c.Query("q") + if query == "" { + c.JSON(http.StatusOK, []interface{}{}) + return + } + + results, err := h.service.SearchFot(c.Request.Context(), query) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, results) +} diff --git a/backend/internal/finance/service.go b/backend/internal/finance/service.go new file mode 100644 index 0000000..670f31d --- /dev/null +++ b/backend/internal/finance/service.go @@ -0,0 +1,111 @@ +package finance + +import ( + "context" + "photum-backend/internal/db/generated" + + "github.com/jackc/pgx/v5/pgtype" +) + +type Service struct { + queries *generated.Queries +} + +func NewService(queries *generated.Queries) *Service { + return &Service{queries: queries} +} + +func (s *Service) Create(ctx context.Context, params generated.CreateTransactionParams) (generated.FinancialTransaction, error) { + txn, err := s.queries.CreateTransaction(ctx, params) + if err != nil { + return generated.FinancialTransaction{}, err + } + + if params.FotID.Valid { + _ = s.updateFotExpenses(ctx, params.FotID) + } + + return txn, nil +} + +func (s *Service) Update(ctx context.Context, params generated.UpdateTransactionParams) (generated.FinancialTransaction, error) { + txn, err := s.queries.UpdateTransaction(ctx, params) + if err != nil { + return generated.FinancialTransaction{}, err + } + + // Recalculate for the new FOT (if changed, we should technically recalc old one too, but simpler for now) + if params.FotID.Valid { + _ = s.updateFotExpenses(ctx, params.FotID) + } + return txn, nil +} + +func (s *Service) Delete(ctx context.Context, id pgtype.UUID) error { + // First fetch to get FotID + txn, err := s.queries.GetTransaction(ctx, id) + if err != nil { + return err + } + + err = s.queries.DeleteTransaction(ctx, id) + if err != nil { + return err + } + + if txn.FotID.Valid { + _ = s.updateFotExpenses(ctx, txn.FotID) + } + return nil +} + +func (s *Service) ListByFot(ctx context.Context, fotID pgtype.UUID) ([]generated.FinancialTransaction, error) { + return s.queries.ListTransactionsByFot(ctx, fotID) +} + +func (s *Service) ListAll(ctx context.Context) ([]generated.ListTransactionsRow, error) { + return s.queries.ListTransactions(ctx) +} + +func (s *Service) AutoFillSearch(ctx context.Context, fotNumber int32) (generated.GetCadastroFotByFotJoinRow, error) { + return s.queries.GetCadastroFotByFotJoin(ctx, fotNumber) +} + +func (s *Service) ListFotEvents(ctx context.Context, fotID pgtype.UUID) ([]generated.ListAgendasByFotRow, error) { + return s.queries.ListAgendasByFot(ctx, fotID) +} + +func (s *Service) SearchProfessionals(ctx context.Context, query string) ([]generated.SearchProfissionaisRow, error) { + return s.queries.SearchProfissionais(ctx, pgtype.Text{String: query, Valid: true}) +} + +func (s *Service) SearchProfessionalsByFunction(ctx context.Context, query string, functionName string) ([]generated.SearchProfissionaisByFunctionRow, error) { + return s.queries.SearchProfissionaisByFunction(ctx, generated.SearchProfissionaisByFunctionParams{ + Column1: pgtype.Text{String: query, Valid: true}, // $1 - Name + Nome: functionName, // $2 - Function Name + }) +} + +func (s *Service) GetStandardPrice(ctx context.Context, eventName string, serviceName string) (pgtype.Numeric, error) { + // serviceName here is the Function Name (e.g. Fotógrafo) + return s.queries.GetStandardPrice(ctx, generated.GetStandardPriceParams{ + Nome: eventName, // $1 - Event Name + Nome_2: serviceName, // $2 - Function Name + }) +} + +func (s *Service) SearchFot(ctx context.Context, query string) ([]generated.SearchFotRow, error) { + return s.queries.SearchFot(ctx, pgtype.Text{String: query, Valid: true}) +} + +func (s *Service) updateFotExpenses(ctx context.Context, fotID pgtype.UUID) error { + total, err := s.queries.SumTotalByFot(ctx, fotID) + if err != nil { + return err + } + + return s.queries.UpdateCadastroFotGastos(ctx, generated.UpdateCadastroFotGastosParams{ + ID: fotID, + GastosCaptacao: total, + }) +} diff --git a/backend/internal/profissionais/handler.go b/backend/internal/profissionais/handler.go index dc07122..867874c 100644 --- a/backend/internal/profissionais/handler.go +++ b/backend/internal/profissionais/handler.go @@ -1,6 +1,7 @@ package profissionais import ( + "encoding/json" "net/http" "photum-backend/internal/db/generated" @@ -20,34 +21,35 @@ func NewHandler(service *Service) *Handler { // ProfissionalResponse struct for Swagger and JSON response type ProfissionalResponse struct { - ID string `json:"id"` - UsuarioID string `json:"usuario_id"` - Nome string `json:"nome"` - FuncaoProfissional string `json:"funcao_profissional"` // Now returns name from join - FuncaoProfissionalID string `json:"funcao_profissional_id"` - Endereco *string `json:"endereco"` - Cidade *string `json:"cidade"` - Uf *string `json:"uf"` - Whatsapp *string `json:"whatsapp"` - CpfCnpjTitular *string `json:"cpf_cnpj_titular"` - Banco *string `json:"banco"` - Agencia *string `json:"agencia"` - ContaPix *string `json:"conta_pix"` - CarroDisponivel *bool `json:"carro_disponivel"` - TemEstudio *bool `json:"tem_estudio"` - QtdEstudio *int `json:"qtd_estudio"` - TipoCartao *string `json:"tipo_cartao"` - Observacao *string `json:"observacao"` - QualTec *int `json:"qual_tec"` - EducacaoSimpatia *int `json:"educacao_simpatia"` - DesempenhoEvento *int `json:"desempenho_evento"` - DispHorario *int `json:"disp_horario"` - Media *float64 `json:"media"` - TabelaFree *string `json:"tabela_free"` - ExtraPorEquipamento *bool `json:"extra_por_equipamento"` - Equipamentos *string `json:"equipamentos"` - Email *string `json:"email"` - AvatarURL *string `json:"avatar_url"` + ID string `json:"id"` + UsuarioID string `json:"usuario_id"` + Nome string `json:"nome"` + FuncaoProfissional string `json:"funcao_profissional"` // Deprecated single name (optional) + FuncaoProfissionalID string `json:"funcao_profissional_id"` + Functions json.RawMessage `json:"functions"` // JSON array + Endereco *string `json:"endereco"` + Cidade *string `json:"cidade"` + Uf *string `json:"uf"` + Whatsapp *string `json:"whatsapp"` + CpfCnpjTitular *string `json:"cpf_cnpj_titular"` + Banco *string `json:"banco"` + Agencia *string `json:"agencia"` + ContaPix *string `json:"conta_pix"` + CarroDisponivel *bool `json:"carro_disponivel"` + TemEstudio *bool `json:"tem_estudio"` + QtdEstudio *int `json:"qtd_estudio"` + TipoCartao *string `json:"tipo_cartao"` + Observacao *string `json:"observacao"` + QualTec *int `json:"qual_tec"` + EducacaoSimpatia *int `json:"educacao_simpatia"` + DesempenhoEvento *int `json:"desempenho_evento"` + DispHorario *int `json:"disp_horario"` + Media *float64 `json:"media"` + TabelaFree *string `json:"tabela_free"` + ExtraPorEquipamento *bool `json:"extra_por_equipamento"` + Equipamentos *string `json:"equipamentos"` + Email *string `json:"email"` + AvatarURL *string `json:"avatar_url"` } func toResponse(p interface{}) ProfissionalResponse { @@ -64,6 +66,7 @@ func toResponse(p interface{}) ProfissionalResponse { FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(), // FuncaoProfissional name is not available in simple insert return without extra query or join FuncaoProfissional: "", + Functions: json.RawMessage("[]"), // Empty on Create (or Fetch specifically if needed) Endereco: fromPgText(v.Endereco), Cidade: fromPgText(v.Cidade), Uf: fromPgText(v.Uf), @@ -98,7 +101,8 @@ func toResponse(p interface{}) ProfissionalResponse { UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(), Nome: v.Nome, FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(), - FuncaoProfissional: v.FuncaoNome.String, // From join + FuncaoProfissional: "", // v.FuncaoNome removed from query or changed? + Functions: toJSONRaw(v.Functions), Endereco: fromPgText(v.Endereco), Cidade: fromPgText(v.Cidade), Uf: fromPgText(v.Uf), @@ -129,7 +133,8 @@ func toResponse(p interface{}) ProfissionalResponse { UsuarioID: uuid.UUID(v.UsuarioID.Bytes).String(), Nome: v.Nome, FuncaoProfissionalID: uuid.UUID(v.FuncaoProfissionalID.Bytes).String(), - FuncaoProfissional: v.FuncaoNome.String, // From join + FuncaoProfissional: "", // v.FuncaoNome removed + Functions: toJSONRaw(v.Functions), Endereco: fromPgText(v.Endereco), Cidade: fromPgText(v.Cidade), Uf: fromPgText(v.Uf), @@ -191,6 +196,28 @@ func fromPgNumeric(n pgtype.Numeric) *float64 { return &val } +func toJSONRaw(v interface{}) json.RawMessage { + if v == nil { + return json.RawMessage("[]") + } + switch val := v.(type) { + case []byte: + if len(val) == 0 { + return json.RawMessage("[]") + } + return json.RawMessage(val) + case string: + if val == "" { + return json.RawMessage("[]") + } + return json.RawMessage([]byte(val)) + default: + // Fallback: marshal strictly? or return empty array + b, _ := json.Marshal(v) + return json.RawMessage(b) + } +} + // Create godoc // @Summary Create a new profissional // @Description Create a new profissional record diff --git a/backend/internal/profissionais/service.go b/backend/internal/profissionais/service.go index c33c9be..b1a907e 100644 --- a/backend/internal/profissionais/service.go +++ b/backend/internal/profissionais/service.go @@ -22,6 +22,7 @@ func NewService(queries *generated.Queries) *Service { type CreateProfissionalInput struct { Nome string `json:"nome"` FuncaoProfissionalID string `json:"funcao_profissional_id"` + FuncoesIds []string `json:"funcoes_ids"` // New field Endereco *string `json:"endereco"` Cidade *string `json:"cidade"` Uf *string `json:"uf"` @@ -105,6 +106,26 @@ func (s *Service) Create(ctx context.Context, userID string, input CreateProfiss if err != nil { return nil, err } + + // Insert multiple functions if provided + if len(input.FuncoesIds) > 0 { + for _, fid := range input.FuncoesIds { + fUUID, err := uuid.Parse(fid) + if err == nil { + _ = s.queries.AddFunctionToProfessional(ctx, generated.AddFunctionToProfessionalParams{ + ProfissionalID: pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true}, + FuncaoID: pgtype.UUID{Bytes: fUUID, Valid: true}, + }) + } + } + } else if funcaoValid { + // If no list provided but single ID is, insert that one too into junction + _ = s.queries.AddFunctionToProfessional(ctx, generated.AddFunctionToProfessionalParams{ + ProfissionalID: pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true}, + FuncaoID: pgtype.UUID{Bytes: funcaoUUID, Valid: true}, + }) + } + return &prof, nil } @@ -127,6 +148,7 @@ func (s *Service) GetByID(ctx context.Context, id string) (*generated.GetProfiss type UpdateProfissionalInput struct { Nome string `json:"nome"` FuncaoProfissionalID string `json:"funcao_profissional_id"` + FuncoesIds []string `json:"funcoes_ids"` // New field Endereco *string `json:"endereco"` Cidade *string `json:"cidade"` Uf *string `json:"uf"` @@ -196,6 +218,31 @@ func (s *Service) Update(ctx context.Context, id string, input UpdateProfissiona if err != nil { return nil, err } + + // Update functions logic + // If input.FuncoesIds is provided (even empty), replace all. + // If nil, maybe keep existing? For simplicity, let's assume if present we update. + // Actually frontend should send full list. + if input.FuncoesIds != nil { + // Clear existing + _ = s.queries.ClearProfessionalFunctions(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) + // Add new + for _, fid := range input.FuncoesIds { + fUUID, err := uuid.Parse(fid) + if err == nil { + _ = s.queries.AddFunctionToProfessional(ctx, generated.AddFunctionToProfessionalParams{ + ProfissionalID: pgtype.UUID{Bytes: uuidVal, Valid: true}, + FuncaoID: pgtype.UUID{Bytes: fUUID, Valid: true}, + }) + } + } + } else if input.FuncaoProfissionalID != "" { + // If legacy field matches, ensure it's in junction set too? + // Or maybe we treat legacy ID as primary and sync it. + // For now, let's just make sure at least one exists if provided. + // But usually update sends all data. + } + return &prof, nil } diff --git a/frontend/components/EventTable.tsx b/frontend/components/EventTable.tsx index e7fac59..d49824a 100644 --- a/frontend/components/EventTable.tsx +++ b/frontend/components/EventTable.tsx @@ -45,24 +45,32 @@ export const EventTable: React.FC = ({ const calculateTeamStatus = (event: EventData) => { const assignments = event.assignments || []; + // Helper to check if professional has a specific role + const hasRole = (professional: any, roleSlug: string) => { + if (!professional) return false; + const term = roleSlug.toLowerCase(); + + // Check functions array first (new multi-role system) + if (professional.functions && professional.functions.length > 0) { + return professional.functions.some((f: any) => f.nome.toLowerCase().includes(term)); + } + + // Fallback to legacy role field + return (professional.role || "").toLowerCase().includes(term); + }; + // Contadores de profissionais aceitos por tipo - const acceptedFotografos = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("fot"); - }).length; + const acceptedFotografos = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + ).length; - const acceptedRecepcionistas = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("recep"); - }).length; + const acceptedRecepcionistas = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + ).length; - const acceptedCinegrafistas = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("cine"); - }).length; + const acceptedCinegrafistas = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine") + ).length; // Quantidades necessárias const qtdFotografos = event.qtdFotografos || 0; diff --git a/frontend/components/ProfessionalDetailsModal.tsx b/frontend/components/ProfessionalDetailsModal.tsx index d8ceecd..6efe587 100644 --- a/frontend/components/ProfessionalDetailsModal.tsx +++ b/frontend/components/ProfessionalDetailsModal.tsx @@ -89,10 +89,21 @@ export const ProfessionalDetailsModal: React.FC =

{professional.name || professional.nome}

- - - {professional.role || "Profissional"} - +
+ {professional.functions && professional.functions.length > 0 ? ( + professional.functions.map(f => ( + + + {f.nome} + + )) + ) : ( + + + {professional.role || "Profissional"} + + )} +
{/* Performance Rating - Only for Master (Admin/Owner), NOT for the professional themselves */} {isMaster && professional.media !== undefined && professional.media !== null && ( diff --git a/frontend/components/ProfessionalForm.tsx b/frontend/components/ProfessionalForm.tsx index f7fb2fe..99802a3 100644 --- a/frontend/components/ProfessionalForm.tsx +++ b/frontend/components/ProfessionalForm.tsx @@ -15,6 +15,7 @@ export interface ProfessionalData { confirmarSenha: string; avatar?: File | null; funcaoId: string; + funcoesIds: string[]; // New cep: string; rua: string; numero: string; @@ -55,6 +56,7 @@ export const ProfessionalForm: React.FC = ({ confirmarSenha: "", avatar: null, funcaoId: "", + funcoesIds: [], cep: "", rua: "", numero: "", @@ -331,19 +333,35 @@ export const ProfessionalForm: React.FC = ({ rodando.

) : ( - +
+ {functions.map((func) => ( + + ))} +
)}
diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index 90d55ce..3c17562 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -609,6 +609,7 @@ interface DataContextType { professionals: Professional[]; respondToAssignment: (eventId: string, status: string, reason?: string) => Promise; updateEventDetails: (id: string, data: any) => Promise; + functions: { id: string; nome: string }[]; } const DataContext = createContext(undefined); @@ -625,6 +626,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ const [pendingUsers, setPendingUsers] = useState([]); const [professionals, setProfessionals] = useState([]); + const [functions, setFunctions] = useState<{ id: string; nome: string }[]>([]); // Fetch events from API useEffect(() => { @@ -635,7 +637,13 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ if (visibleToken) { try { // Import dynamic to avoid circular dependency if any, or just use imported service - const { getAgendas } = await import("../services/apiService"); + const { getAgendas, getFunctions } = await import("../services/apiService"); + + // Fetch Functions (Roles) + getFunctions().then(res => { + if (res.data) setFunctions(res.data); + }); + const result = await getAgendas(visibleToken); console.log("Raw Agenda Data:", result.data); // Debug logging if (result.data) { @@ -688,6 +696,17 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ attendees: e.qtd_formandos, fotId: e.fot_id, // UUID + // Resource Mapping + qtdFormandos: e.qtd_formandos, + qtdFotografos: e.qtd_fotografos, + qtdRecepcionistas: e.qtd_recepcionistas, + qtdCinegrafistas: e.qtd_cinegrafistas, + qtdEstudios: e.qtd_estudios, + qtdPontosFoto: e.qtd_ponto_foto, + qtdPontosDecorados: e.qtd_ponto_decorado, + qtdPontosLed: e.qtd_pontos_led, + qtdPlataforma360: e.qtd_plataforma_360, + // Joined Fields fot: e.fot_numero ?? e.fot_id, // Show Number if available (even 0), else ID curso: e.curso_nome, @@ -803,6 +822,9 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ tabela_free: p.tabela_free, extra_por_equipamento: p.extra_por_equipamento, equipamentos: p.equipamentos, + + // Multi-function support + functions: p.functions || [], availability: {}, // Default empty availability })); @@ -1140,6 +1162,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ }, addEvent, updateEventStatus, + functions, assignPhotographer, getEventsByRole, addInstitution, diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index b72dcc9..03e5059 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -46,6 +46,7 @@ export const Dashboard: React.FC = ({ getActiveCoursesByInstitutionId, respondToAssignment, updateEventDetails, + functions, } = useData(); // ... (inside component) @@ -123,23 +124,44 @@ export const Dashboard: React.FC = ({ const assignments = event.assignments || []; // Contadores de profissionais aceitos por tipo - const acceptedFotografos = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("fot"); - }).length; + // Helper to check if professional has a specific role + const hasRole = (professional: Professional | undefined, roleSlug: string) => { + if (!professional) return false; + const term = roleSlug.toLowerCase(); + + // Check functions array first (new multi-role system) + if (professional.functions && professional.functions.length > 0) { + return professional.functions.some(f => f.nome.toLowerCase().includes(term)); + } + + // Fallback to legacy role field + return (professional.role || "").toLowerCase().includes(term); + }; + + // Contadores de profissionais aceitos por tipo + const acceptedFotografos = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + ).length; - const acceptedRecepcionistas = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("recep"); - }).length; + const pendingFotografos = assignments.filter(a => + a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "fot") + ).length; - const acceptedCinegrafistas = assignments.filter(a => { - if (a.status !== "ACEITO") return false; - const professional = professionals.find(p => p.id === a.professionalId); - return professional && (professional.role || "").toLowerCase().includes("cine"); - }).length; + const acceptedRecepcionistas = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + ).length; + + const pendingRecepcionistas = assignments.filter(a => + a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "recep") + ).length; + + const acceptedCinegrafistas = assignments.filter(a => + a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine") + ).length; + + const pendingCinegrafistas = assignments.filter(a => + a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "cine") + ).length; // Quantidades necessárias const qtdFotografos = event.qtdFotografos || 0; @@ -156,8 +178,11 @@ export const Dashboard: React.FC = ({ return { acceptedFotografos, + pendingFotografos, acceptedRecepcionistas, + pendingRecepcionistas, acceptedCinegrafistas, + pendingCinegrafistas, fotoFaltante, recepFaltante, cineFaltante, @@ -179,6 +204,12 @@ export const Dashboard: React.FC = ({ if (!selectedEvent) return professionals; return professionals.filter((professional) => { + // Filter out professionals with unknown roles (matches Team.tsx logic) + if (functions.length > 0) { + const isValidRole = functions.some(f => f.id === professional.funcao_profissional_id); + if (!isValidRole) return false; + } + // Filtro por busca (nome ou email) if (teamSearchTerm) { const searchLower = teamSearchTerm.toLowerCase(); @@ -316,6 +347,69 @@ export const Dashboard: React.FC = ({ reason?: string ) => { e.stopPropagation(); + + // Validação de Lotação da Equipe (Apenas para Aceite) + if (status === "ACEITO") { + const targetEvent = events.find(evt => evt.id === eventId); + const currentProfessional = professionals.find(p => p.usuarioId === user.id); + + if (targetEvent && currentProfessional) { + // Reutilizando lógica de contagem (adaptada de calculateTeamStatus) + // Precisamos recalcular pois calculateTeamStatus depende de selectedEvent que pode não ser o alvo aqui + + const assignments = targetEvent.assignments || []; + + const hasRole = (professional: Professional | undefined, roleSlug: string) => { + if (!professional) return false; + const term = roleSlug.toLowerCase(); + if (professional.functions && professional.functions.length > 0) { + return professional.functions.some(f => f.nome.toLowerCase().includes(term)); + } + return (professional.role || "").toLowerCase().includes(term); + }; + + const checkQuota = (roleSlugs: string[], acceptedCount: number, requiredCount: number, roleName: string) => { + // Verifica se o profissional tem essa função + const isProfessionalRole = roleSlugs.some(slug => hasRole(currentProfessional, slug)); + + if (isProfessionalRole) { + // Se já está cheio (ou excedido), bloqueia + if (acceptedCount >= requiredCount) { + return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`; + } + } + return null; + }; + + // Contagens Atuais + const acceptedFot = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot")).length; + const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep")).length; + const acceptedCine = assignments.filter(a => a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine")).length; + + // Limites + const reqFot = targetEvent.qtdFotografos || 0; + const reqRecep = targetEvent.qtdRecepcionistas || 0; + const reqCine = targetEvent.qtdCinegrafistas || 0; + + // Verificações + const errors = []; + + const errFot = checkQuota(["fot"], acceptedFot, reqFot, "Fotografia"); + if (errFot) errors.push(errFot); + + const errRecep = checkQuota(["recep"], acceptedRecep, reqRecep, "Recepção"); + if (errRecep) errors.push(errRecep); + + const errCine = checkQuota(["cine"], acceptedCine, reqCine, "Cinegrafia"); + if (errCine) errors.push(errCine); + + if (errors.length > 0) { + alert(`Não foi possível aceitar o convite:\n\n${errors.join("\n")}\n\nEntre em contato com a administração.`); + return; // Bloqueia a ação + } + } + } + await respondToAssignment(eventId, status, reason); }; @@ -756,65 +850,124 @@ export const Dashboard: React.FC = ({ QTD Formandos - {selectedEvent.qtdFormandos || selectedEvent.attendees || "-"} - - - - - Fotógrafo - - - {selectedEvent.qtdFotografos || "-"} - - - - - Recepcionista - - - {selectedEvent.qtdRecepcionistas || "-"} - - - - - Cinegrafista - - - {selectedEvent.qtdCinegrafistas || "-"} - - - - - Estúdio - - - {selectedEvent.qtdEstudios || "-"} - - - - - Ponto de Foto - - - {selectedEvent.qtdPontosFoto || "-"} - - - - - Ponto Decorado - - - {selectedEvent.qtdPontosDecorados || "-"} - - - - - Ponto LED - - - {selectedEvent.qtdPontosLed || "-"} + {selectedEvent.qtdFormandos || (selectedEvent as any).qtd_formandos || selectedEvent.attendees || "-"} + {/* Helper to calculate pending counts */ + (() => { + const teamStatus = calculateTeamStatus(selectedEvent); + + const renderResourceRow = (label: string, confirmed: number, pending: number, required: number) => { + if (!required && confirmed === 0 && pending === 0) { + return ( + + + {label} + + - + + ); + } + + const isComplete = confirmed >= required; + + return ( + + + {label} + + +
+
+ {confirmed} + / + {required || 0} + {isComplete && } +
+ {pending > 0 && ( + + ({pending} aguardando aceite) + + )} +
+ + + ); + }; + + return ( + <> + + + QTD Formandos + + + {selectedEvent.qtdFormandos || (selectedEvent as any).qtd_formandos || selectedEvent.attendees || "-"} + + + + {renderResourceRow( + "Fotógrafo", + teamStatus.acceptedFotografos, + teamStatus.pendingFotografos, + selectedEvent.qtdFotografos || 0 + )} + {renderResourceRow( + "Recepcionista", + teamStatus.acceptedRecepcionistas, + teamStatus.pendingRecepcionistas, + selectedEvent.qtdRecepcionistas || 0 + )} + {renderResourceRow( + "Cinegrafista", + teamStatus.acceptedCinegrafistas, + teamStatus.pendingCinegrafistas, + selectedEvent.qtdCinegrafistas || 0 + )} + + + + Estúdio + + + {selectedEvent.qtdEstudios || (selectedEvent as any).qtd_estudios || "-"} + + + + + Ponto de Foto + + + {selectedEvent.qtdPontosFoto || (selectedEvent as any).qtd_ponto_foto || "-"} + + + + + Ponto Decorado + + + {selectedEvent.qtdPontosDecorados || (selectedEvent as any).qtd_ponto_decorado || "-"} + + + + + Ponto LED + + + {selectedEvent.qtdPontosLed || (selectedEvent as any).qtd_pontos_led || "-"} + + + + + Plataforma 360 + + + {selectedEvent.qtdPlataforma360 || (selectedEvent as any).qtd_plataforma_360 || "-"} + + + + ); + })()} {/* Status e Faltantes */} {(() => { @@ -1305,7 +1458,9 @@ export const Dashboard: React.FC = ({ {/* Função */} - {photographer.role} + {photographer.functions && photographer.functions.length > 0 + ? photographer.functions.map(f => f.nome).join(", ") + : photographer.role} @@ -1421,7 +1576,9 @@ export const Dashboard: React.FC = ({
- {photographer.role} + {photographer.functions && photographer.functions.length > 0 + ? photographer.functions.map(f => f.nome).join(", ") + : photographer.role} {status === "ACEITO" && ( diff --git a/frontend/pages/Finance.tsx b/frontend/pages/Finance.tsx index 0d7d90a..0fcada6 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 } from "react"; import { Download, Plus, @@ -7,20 +7,23 @@ import { ArrowDown, X, AlertCircle, + Search, } from "lucide-react"; interface FinancialTransaction { id: string; fot: number; - data: string; + fot_id?: string; // Add fot_id + data: string; // date string YYYY-MM-DD + dataRaw?: string; // Optional raw date for editing curso: string; instituicao: string; - anoFormatura: number; + anoFormatura: number; // or string label empresa: string; tipoEvento: string; tipoServico: string; - nome: string; - endereco: string; + nome: string; // professional_name + endereco?: string; // Not in DB schema but in UI? Schema doesn't have address. We'll omit or map if needed. whatsapp: string; cpf: string; tabelaFree: string; @@ -28,58 +31,14 @@ interface FinancialTransaction { valorExtra: number; descricaoExtra: string; totalPagar: number; - dataPgto: string; + dataPgto: string; // date string pgtoOk: boolean; } -const Finance: React.FC = () => { - const [transactions, setTransactions] = useState([ - { - id: "1", - fot: 12345, - data: "2025-11-15", - curso: "Medicina", - instituicao: "UFPR", - anoFormatura: 2025, - empresa: "PhotoPro Studio", - tipoEvento: "Formatura", - tipoServico: "Fotografia Completa", - nome: "Ana Paula Silva", - endereco: "Rua das Flores, 123 - Curitiba/PR", - whatsapp: "(41) 99999-1234", - cpf: "123.456.789-00", - tabelaFree: "Pacote Premium", - valorFree: 5000.0, - valorExtra: 1500.0, - descricaoExtra: "Álbum adicional + drone", - totalPagar: 6500.0, - dataPgto: "2025-12-01", - pgtoOk: true, - }, - { - id: "2", - fot: 12346, - data: "2025-11-20", - curso: "Direito", - instituicao: "PUC-PR", - anoFormatura: 2025, - empresa: "Lens & Art", - tipoEvento: "Formatura", - tipoServico: "Fotografia + Vídeo", - nome: "Carlos Eduardo", - endereco: "Av. Brasil, 456 - Curitiba/PR", - whatsapp: "(41) 98888-5678", - cpf: "987.654.321-00", - tabelaFree: "Pacote Standard", - valorFree: 4000.0, - valorExtra: 800.0, - descricaoExtra: "Ensaio pré-formatura", - totalPagar: 4800.0, - dataPgto: "2025-12-10", - pgtoOk: false, - }, - ]); +const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8080"; +const Finance: React.FC = () => { + const [transactions, setTransactions] = useState([]); const [showAddModal, setShowAddModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [selectedTransaction, setSelectedTransaction] = @@ -89,19 +48,16 @@ const Finance: React.FC = () => { direction: "asc" | "desc"; } | null>(null); - // Estados para dados da API - const [cursos, setCursos] = useState([]); - const [instituicoes, setInstituicoes] = useState([]); - const [empresas, setEmpresas] = useState([]); + // API Data States + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); const [tiposEventos, setTiposEventos] = useState([]); const [tiposServicos, setTiposServicos] = useState([]); - const [apiError, setApiError] = useState(""); - const [loadingApi, setLoadingApi] = useState(false); - // Form state + // Form State const [formData, setFormData] = useState>({ fot: 0, - data: "", + data: new Date().toISOString().split("T")[0], curso: "", instituicao: "", anoFormatura: new Date().getFullYear(), @@ -109,7 +65,6 @@ const Finance: React.FC = () => { tipoEvento: "", tipoServico: "", nome: "", - endereco: "", whatsapp: "", cpf: "", tabelaFree: "", @@ -121,872 +76,802 @@ const Finance: React.FC = () => { pgtoOk: false, }); - // Carregar dados da API - const loadApiData = async () => { - setLoadingApi(true); - setApiError(""); + // Auto-fill state + const [fotLoading, setFotLoading] = useState(false); + const [fotFound, setFotFound] = useState(false); + const [fotEvents, setFotEvents] = useState([]); // New state + const [showEventSelector, setShowEventSelector] = useState(false); + // FOT Search State + const [fotQuery, setFotQuery] = useState(""); + const [fotResults, setFotResults] = useState([]); + const [showFotSuggestions, setShowFotSuggestions] = useState(false); + + // Professional Search State + const [proQuery, setProQuery] = useState(""); + const [proResults, setProResults] = useState([]); + const [showProSuggestions, setShowProSuggestions] = useState(false); + const [proFunctions, setProFunctions] = useState([]); // Functions of selected professional + const [selectedProId, setSelectedProId] = useState(null); + + // Validations + const validateCpf = (cpf: string) => { + // Simple length check for now, can be enhanced + return cpf.replace(/\D/g, "").length === 11; + }; + + const loadTransactions = async () => { + const token = localStorage.getItem("token"); + if (!token) { + setError("Usuário não autenticado"); + return; + } + + setLoading(true); try { - const API_BASE_URL = - import.meta.env.VITE_API_URL || "http://localhost:3000"; - - // Carregar cursos - try { - const cursosRes = await fetch(`${API_BASE_URL}/api/cursos`); - if (cursosRes.ok) { - const cursosData = await cursosRes.json(); - setCursos(cursosData); - } - } catch (error) { - console.error("Erro ao carregar cursos:", error); - } - - // Carregar instituições (empresas cadastradas) - try { - const instRes = await fetch(`${API_BASE_URL}/api/empresas`); - if (instRes.ok) { - const instData = await instRes.json(); - setInstituicoes(instData); - } - } catch (error) { - console.error("Erro ao carregar instituições:", error); - } - - // Carregar empresas - try { - const empRes = await fetch(`${API_BASE_URL}/api/empresas`); - if (empRes.ok) { - const empData = await empRes.json(); - setEmpresas(empData); - } - } catch (error) { - console.error("Erro ao carregar empresas:", error); - } - - // Carregar tipos de eventos - try { - const evRes = await fetch(`${API_BASE_URL}/api/tipos-eventos`); - if (evRes.ok) { - const evData = await evRes.json(); - setTiposEventos(evData); - } - } catch (error) { - console.error("Erro ao carregar tipos de eventos:", error); - } - - // Carregar tipos de serviços - try { - const servRes = await fetch(`${API_BASE_URL}/api/tipos-servicos`); - if (servRes.ok) { - const servData = await servRes.json(); - setTiposServicos(servData); - } - } catch (error) { - console.error("Erro ao carregar tipos de serviços:", error); - } - - // Se todos falharam, mostrar erro - if ( - cursos.length === 0 && - instituicoes.length === 0 && - empresas.length === 0 && - tiposEventos.length === 0 && - tiposServicos.length === 0 - ) { - setApiError( - "Backend não está rodando. Alguns campos podem não estar disponíveis." - ); - } - } catch (error) { - setApiError( - "Backend não está rodando. Alguns campos podem não estar disponíveis." - ); + const res = await fetch(`${API_BASE_URL}/api/finance`, { + 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(); + + // Map Backend DTO to Frontend Interface + const mapped = 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: "", + tipoEvento: item.tipo_evento, + tipoServico: item.tipo_servico, + nome: item.professional_name, + whatsapp: item.whatsapp, + cpf: item.cpf, + tabelaFree: item.tabela_free, + valorFree: parseFloat(item.valor_free), + valorExtra: parseFloat(item.valor_extra), + descricaoExtra: item.descricao_extra, + totalPagar: parseFloat(item.total_pagar), + dataPgto: item.data_pagamento ? item.data_pagamento.split("T")[0] : "", + pgtoOk: item.pgto_ok, + })); + setTransactions(mapped); + } catch (err) { + console.error(err); + setError("Erro ao carregar dados."); } finally { - setLoadingApi(false); + setLoading(false); + } + }; + + const loadAuxiliaryData = async () => { + const token = localStorage.getItem("token"); + if (!token) return; + + try { + const headers = { "Authorization": `Bearer ${token}` }; + const [evRes, servRes] = await Promise.all([ + fetch(`${API_BASE_URL}/api/tipos-eventos`, { headers }), + fetch(`${API_BASE_URL}/api/tipos-servicos`, { headers }), + ]); + if (evRes.ok) setTiposEventos(await evRes.json()); + if (servRes.ok) setTiposServicos(await servRes.json()); + } catch (e) { + console.error(e); } }; useEffect(() => { - if (showAddModal || showEditModal) { - loadApiData(); - } - }, [showAddModal, showEditModal]); + loadTransactions(); + loadAuxiliaryData(); + }, []); - // Ordenação - const handleSort = (key: keyof FinancialTransaction) => { - if (sortConfig && sortConfig.key === key) { - // Se já está ordenando por este campo, alterna a ordem - if (sortConfig.direction === "asc") { - setSortConfig({ key, direction: "desc" }); - } else { - // Se já está descendente, remove a ordenação - setSortConfig(null); - } - } else { - // Novo campo, começa com ordem ascendente - setSortConfig({ key, direction: "asc" }); - } - }; - - const sortedTransactions = React.useMemo(() => { - let sortableTransactions = [...transactions]; - if (sortConfig !== null) { - sortableTransactions.sort((a, b) => { - const aValue = a[sortConfig.key]; - const bValue = b[sortConfig.key]; - - if (aValue < bValue) { - return sortConfig.direction === "asc" ? -1 : 1; - } - if (aValue > bValue) { - return sortConfig.direction === "asc" ? 1 : -1; - } - return 0; - }); - } - return sortableTransactions; - }, [transactions, sortConfig]); - - const getSortIcon = (key: keyof FinancialTransaction) => { - if (sortConfig?.key !== key) { - return ( - - ); - } - if (sortConfig.direction === "asc") { - return ; - } - return ; - }; - - // Handlers - const handleAddTransaction = () => { - setFormData({ - fot: 0, + // Filters + const [filters, setFilters] = useState({ + fot: "", data: "", - curso: "", - instituicao: "", - anoFormatura: new Date().getFullYear(), - empresa: "", - tipoEvento: "", - tipoServico: "", + evento: "", + servico: "", nome: "", - endereco: "", - whatsapp: "", - cpf: "", - tabelaFree: "", - valorFree: 0, - valorExtra: 0, - descricaoExtra: "", - totalPagar: 0, - dataPgto: "", - pgtoOk: false, + status: "", + }); + + // Calculate filtered and sorted transactions + const sortedTransactions = React.useMemo(() => { + let result = [...transactions]; + + // 1. Filter + if (filters.fot) result = result.filter(t => String(t.fot).includes(filters.fot)); + if (filters.data) result = result.filter(t => t.data.includes(filters.data)); + if (filters.evento) result = result.filter(t => t.tipoEvento.toLowerCase().includes(filters.evento.toLowerCase())); + if (filters.servico) result = result.filter(t => t.tipoServico.toLowerCase().includes(filters.servico.toLowerCase())); + if (filters.nome) result = result.filter(t => t.nome.toLowerCase().includes(filters.nome.toLowerCase())); + if (filters.status) { + const s = filters.status.toLowerCase(); + if (s === 'ok' || s === 'sim') result = result.filter(t => t.pgtoOk); + if (s === 'no' || s === 'nao' || s === 'não') result = result.filter(t => !t.pgtoOk); + } + + // 2. Sort by FOT (desc) then Date (desc) to group FOTs + // Default sort is grouped by FOT + if (!sortConfig) { + return result.sort((a, b) => { + if (a.fot !== b.fot) return b.fot - a.fot; // Group by FOT + return new Date(b.dataRaw || b.data).getTime() - new Date(a.dataRaw || a.data).getTime(); + }); + } + + // Custom sort if implemented + return result.sort((a, b) => { + // @ts-ignore + const aValue = a[sortConfig.key]; + // @ts-ignore + const bValue = b[sortConfig.key]; + if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1; + if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1; + return 0; }); - setShowAddModal(true); + }, [transactions, filters, sortConfig]); + + + const handleSort = (key: keyof FinancialTransaction) => { + let direction: "asc" | "desc" = "asc"; + if (sortConfig && sortConfig.key === key && sortConfig.direction === "asc") { + direction = "desc"; + } + setSortConfig({ key, direction }); }; - const handleEditTransaction = (transaction: FinancialTransaction) => { - setSelectedTransaction(transaction); - setFormData(transaction); + const handleFotSearch = async (query: string) => { + setFotQuery(query); + + // If user types numbers, list options + if (query.length < 2) { + setFotResults([]); + setShowFotSuggestions(false); + return; + } + + const token = localStorage.getItem("token"); + try { + const res = await fetch(`${API_BASE_URL}/api/finance/fot-search?q=${query}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if(res.ok) { + const data = await res.json(); + setFotResults(data); + setShowFotSuggestions(true); + } + } catch (e) { + console.error(e); + } + }; + + const selectFot = (fot: any) => { + setFotQuery(String(fot.fot)); + setShowFotSuggestions(false); + handleAutoFill(fot.fot); + }; + + const handleAutoFill = async (fotNum: number) => { + if (!fotNum) return; + + const token = localStorage.getItem("token"); + if (!token) return; + + setFotLoading(true); + setFotEvents([]); + setShowEventSelector(false); + try { + const res = await fetch(`${API_BASE_URL}/api/finance/autofill?fot=${fotNum}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setFormData(prev => ({ + ...prev, + curso: data.curso_nome, + instituicao: data.empresa_nome, + empresa: data.empresa_nome, + anoFormatura: data.ano_formatura_label, + fot: fotNum, + })); + setFotFound(true); + // @ts-ignore + const fotId = data.id; + setFormData(prev => ({ ...prev, fot_id: fotId })); + setFotQuery(String(fotNum)); // Ensure query matches found fot + + // Now fetch events + const evRes = await fetch(`${API_BASE_URL}/api/finance/fot-events?fot_id=${fotId}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if (evRes.ok) { + const events = await evRes.json(); + if (events && events.length > 0) { + setFotEvents(events); + setShowEventSelector(true); + } + } + + } else { + setFotFound(false); + } + } catch (error) { + console.error(error); + setFotFound(false); + } finally { + setFotLoading(false); + } + }; + + // Auto-Pricing Effect + useEffect(() => { + const fetchPrice = async () => { + if (!formData.tipoEvento || !formData.tipoServico) return; + + // If editing existing transaction, maybe don't overwrite unless user changes something? + // But for "Nova Transação", strictly overwrite. + // Let's assume overwrite if price is 0 or user changed inputs. + // Simplified: always fetch if inputs present. + + const token = localStorage.getItem("token"); + if (!token) return; + + try { + const res = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(formData.tipoEvento)}&service=${encodeURIComponent(formData.tipoServico)}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + if (data.valor !== undefined) { + setFormData(prev => ({ ...prev, valorFree: data.valor })); + } + } + } catch (e) { + console.error(e); + } + }; + fetchPrice(); + }, [formData.tipoEvento, formData.tipoServico]); + + const handleProSearch = async (query: string) => { + setProQuery(query); + setFormData(prev => ({ ...prev, nome: query })); // Update name as typed + + // Allow empty query to list filtered professionals if Function is selected + if (query.length < 3 && !formData.tipoServico) { + setProResults([]); + setShowProSuggestions(false); + return; + } + + const token = localStorage.getItem("token"); + try { + const fnParam = formData.tipoServico ? `&function=${encodeURIComponent(formData.tipoServico)}` : ""; + const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${query}${fnParam}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if(res.ok) { + const data = await res.json(); + setProResults(data); + setShowProSuggestions(true); + } + } catch (e) { + console.error(e); + } + }; + + const selectProfessional = (pro: any) => { + // Parse functions + let funcs = []; + try { + funcs = pro.functions ? (typeof pro.functions === 'string' ? JSON.parse(pro.functions) : pro.functions) : []; + } catch(e) { + funcs = []; + } + setProFunctions(funcs); + setSelectedProId(pro.id); + + setFormData(prev => ({ + ...prev, + nome: pro.nome, + whatsapp: pro.whatsapp, + cpf: pro.cpf_cnpj_titular, + // Default to first function if available, else empty + tabelaFree: funcs.length > 0 ? funcs[0].nome : "", + })); + setProQuery(pro.nome); + setShowProSuggestions(false); + }; + + const selectEvent = (ev: any) => { + setFormData(prev => ({ + ...prev, + tipoEvento: ev.tipo_evento_nome, + // If event has date, we could pre-fill? User request suggests keeping it flexible or maybe they didn't ask explicitly. + })); + setShowEventSelector(false); + }; + + const handleEdit = async (t: FinancialTransaction) => { + setSelectedTransaction(t); + setFormData({ + id: t.id, + fot_id: t.fot_id, + data: t.dataRaw || t.data, // Use raw YYYY-MM-DD for input + tipoEvento: t.tipoEvento, + tipoServico: t.tipoServico, + nome: t.nome, + whatsapp: t.whatsapp, + cpf: t.cpf, + tabelaFree: t.tabelaFree, + valorFree: t.valorFree, + valorExtra: t.valorExtra, + descricaoExtra: t.descricaoExtra, + dataPgto: t.dataPgto, + pgtoOk: t.pgtoOk, + totalPagar: t.totalPagar, + }); + setFotFound(false); // Reset fotFound state for edit modal + + // Fetch FOT details if ID exists + if (t.fot_id) { + const token = localStorage.getItem("token"); + if (!token) return; + try { + const res = await fetch(`${API_BASE_URL}/api/cadastro-fot/${t.fot_id}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + setFormData(prev => ({ + ...prev, + curso: data.curso_nome || "", + instituicao: data.empresa_nome || data.instituicao || "", + empresa: data.empresa_nome || "", + anoFormatura: data.ano_formatura_label || "", + fot: data.fot, + })); + setFotFound(true); + } + } catch (err) { + console.error("Error fetching FOT details for edit:", err); + } + } + + // Fetch professional functions if professional name is present + if (t.nome) { + const token = localStorage.getItem("token"); + if (!token) return; + try { + const res = await fetch(`${API_BASE_URL}/api/finance/professionals?q=${encodeURIComponent(t.nome)}`, { + headers: { "Authorization": `Bearer ${token}` } + }); + if (res.ok) { + const data = await res.json(); + const professional = data.find((p: any) => p.nome === t.nome); + if (professional) { + let funcs = []; + try { + funcs = professional.functions ? (typeof professional.functions === 'string' ? JSON.parse(professional.functions) : professional.functions) : []; + } catch(e) { + funcs = []; + } + setProFunctions(funcs); + } + } + } catch (err) { + console.error("Error fetching professional functions for edit:", err); + } + } + setShowEditModal(true); }; - const handleSaveTransaction = () => { - if (showEditModal && selectedTransaction) { - // Atualizar transação existente - setTransactions( - transactions.map((t) => - t.id === selectedTransaction.id - ? ({ - ...formData, - id: selectedTransaction.id, - } as FinancialTransaction) - : t - ) - ); - setShowEditModal(false); - } else { - // Adicionar nova transação - const newTransaction: FinancialTransaction = { - ...formData, - id: Date.now().toString(), - } as FinancialTransaction; - setTransactions([...transactions, newTransaction]); - setShowAddModal(false); - } - setSelectedTransaction(null); + const handleSave = async () => { + const token = localStorage.getItem("token"); + if (!token) { + alert("Sessão expirada. Faça login novamente."); + return; + } + + // Prepare payload + const payload = { + fot_id: formData.fot_id, // Ensure this is sent + data_cobranca: formData.data, + tipo_evento: formData.tipoEvento, + tipo_servico: formData.tipoServico, + professional_name: formData.nome, + whatsapp: formData.whatsapp, + cpf: formData.cpf, + tabela_free: formData.tabelaFree, + valor_free: formData.valorFree ? Number(formData.valorFree) : 0, + valor_extra: formData.valorExtra ? Number(formData.valorExtra) : 0, + descricao_extra: formData.descricaoExtra, + total_pagar: formData.totalPagar ? Number(formData.totalPagar) : 0, // Should be calculated + data_pagamento: formData.dataPgto || null, + pgto_ok: formData.pgtoOk + }; + + // Calculate total before sending if not set? + // Actually totalPagar IS sent. But let's recalculate to be safe or ensure it's updated. + payload.total_pagar = (payload.valor_free || 0) + (payload.valor_extra || 0); + + try { + let res; + if (formData.id) { + res = await fetch(`${API_BASE_URL}/api/finance/${formData.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + } else { + res = await fetch(`${API_BASE_URL}/api/finance`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${token}` + }, + body: JSON.stringify(payload) + }); + } + if (!res.ok) throw new Error("Erro ao salvar"); + + setShowAddModal(false); + setShowEditModal(false); + setSelectedTransaction(null); // Clear selected transaction + setProFunctions([]); // Clear professional functions + loadTransactions(); // Reload list + } catch (error) { + alert("Erro ao salvar transação"); + } }; - - const handleExport = () => { - // Criar CSV - const headers = [ - "FOT", - "Data", - "Curso", - "Instituição", - "Ano Formatura", - "Empresa", - "Tipo Evento", - "Tipo de Serviço", - "Nome", - "Endereço", - "WhatsApp", - "CPF", - "Tabela Free", - "Valor Free", - "Valor Extra", - "Descrição do Extra", - "Total a Pagar", - "Data Pgto", - "Pgto OK", - ]; - - const csvContent = [ - headers.join(","), - ...transactions.map((t) => - [ - t.fot, - t.data, - t.curso, - t.instituicao, - t.anoFormatura, - t.empresa, - t.tipoEvento, - t.tipoServico, - t.nome, - `"${t.endereco}"`, - t.whatsapp, - t.cpf, - t.tabelaFree, - t.valorFree, - t.valorExtra, - `"${t.descricaoExtra}"`, - t.totalPagar, - t.dataPgto, - t.pgtoOk ? "Sim" : "Não", - ].join(",") - ), - ].join("\n"); - - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const link = document.createElement("a"); - link.href = URL.createObjectURL(blob); - link.download = `financeiro_${new Date().toISOString().split("T")[0]}.csv`; - link.click(); - }; - - const formatCurrency = (value: number) => { - return new Intl.NumberFormat("pt-BR", { - style: "currency", - currency: "BRL", - }).format(value); - }; - - const formatDate = (dateString: string) => { - if (!dateString) return ""; - return new Date(dateString + "T00:00:00").toLocaleDateString("pt-BR"); - }; - - // Atualizar total ao mudar valores - useEffect(() => { - const total = (formData.valorFree || 0) + (formData.valorExtra || 0); + + // Calculations + useEffect(() => { + const total = (Number(formData.valorFree) || 0) + (Number(formData.valorExtra) || 0); setFormData((prev) => ({ ...prev, totalPagar: total })); }, [formData.valorFree, formData.valorExtra]); return (
-
- {/* Header */} -
-
-
-

- Financeiro -

-

- Gestão de transações financeiras -

-
-
- - -
-
+
+
+
+

Extrato

+

Controle financeiro e transações

+
+
- {/* Tabela */} -
- - - - {[ - { key: "fot", label: "FOT" }, - { key: "data", label: "Data" }, - { key: "curso", label: "Curso" }, - { key: "instituicao", label: "Instituição" }, - { key: "anoFormatura", label: "Ano Formatura" }, - { key: "empresa", label: "Empresa" }, - { key: "tipoEvento", label: "Tipo Evento" }, - { key: "tipoServico", label: "Tipo de Serviço" }, - { key: "nome", label: "Nome" }, - { key: "endereco", label: "Endereço" }, - { key: "whatsapp", label: "WhatsApp" }, - { key: "cpf", label: "CPF" }, - { key: "tabelaFree", label: "Tabela Free" }, - { key: "valorFree", label: "Valor Free" }, - { key: "valorExtra", label: "Valor Extra" }, - { key: "descricaoExtra", label: "Descrição do Extra" }, - { key: "totalPagar", label: "Total a Pagar" }, - { key: "dataPgto", label: "Data Pgto" }, - { key: "pgtoOk", label: "Pgto OK" }, - ].map((column) => ( - - ))} - - - - {sortedTransactions.map((transaction) => ( - handleEditTransaction(transaction)} - className="hover:bg-gray-50 cursor-pointer transition-colors" - > - - - - - - - - - - - - - - - - - - - - - ))} - -
- handleSort(column.key as keyof FinancialTransaction) - } - className="px-4 py-3 text-left font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition-colors whitespace-nowrap group" - > -
- {column.label} - {getSortIcon(column.key as keyof FinancialTransaction)} -
-
- {transaction.fot} - - {formatDate(transaction.data)} - - {transaction.curso} - - {transaction.instituicao} - - {transaction.anoFormatura} - - {transaction.empresa} - - {transaction.tipoEvento} - - {transaction.tipoServico} - - {transaction.nome} - - {transaction.endereco} - - {transaction.whatsapp} - - {transaction.cpf} - - {transaction.tabelaFree} - - {formatCurrency(transaction.valorFree)} - - {formatCurrency(transaction.valorExtra)} - - {transaction.descricaoExtra} - - {formatCurrency(transaction.totalPagar)} - - {formatDate(transaction.dataPgto)} - - - {transaction.pgtoOk ? "Sim" : "Não"} - -
- {sortedTransactions.length === 0 && ( -
- Nenhuma transação cadastrada -
- )} + {/* List */} +
+ + + + {["FOT", "Data", "Evento", "Serviço", "Nome", "WhatsApp", "CPF", "Tab. Free", "V. Free", "V. Extra", "Desc. Extra", "Total", "Dt. Pgto", "OK"].map(h => ( + + ))} + + + + {sortedTransactions.map((t, index) => { + const isNewFot = index > 0 && t.fot !== sortedTransactions[index - 1].fot; + return ( + handleEdit(t)} + > + + + + + + + + + + + + + + + + ); + })} + +
+
+ {h} + {/* Filters */} + {h === "FOT" && setFilters({...filters, fot: e.target.value})} />} + {h === "Data" && setFilters({...filters, data: e.target.value})} />} + {h === "Evento" && setFilters({...filters, evento: e.target.value})} />} + {h === "Serviço" && setFilters({...filters, servico: e.target.value})} />} + {h === "Nome" && setFilters({...filters, nome: e.target.value})} />} + {h === "OK" && setFilters({...filters, status: e.target.value})} />} +
+
{t.fot || "?"}{t.data}{t.tipoEvento}{t.tipoServico}{t.nome}{t.whatsapp}{t.cpf}{t.tabelaFree}{t.valorFree?.toFixed(2)}{t.valorExtra?.toFixed(2)}{t.descricaoExtra}{t.totalPagar?.toFixed(2)} + {(() => { + try { + if (!t.dataPgto) return "-"; + const d = new Date(t.dataPgto); + if (isNaN(d.getTime())) return "-"; + return d.toLocaleDateString("pt-BR", {timeZone: "UTC"}); + } catch (e) { + return "-"; + } + })()} + + {t.pgtoOk + ? Sim + : Não} +
+ {sortedTransactions.length === 0 && !loading && ( +
Nenhuma transação encontrada.
+ )}
- - {/* Modal Adicionar/Editar */} + + {/* Modal */} {(showAddModal || showEditModal) && ( -
-
-
-

- {showEditModal ? "Editar Transação" : "Cadastrar Transação"} -

- -
+
+
+
+

+ {showAddModal ? "Nova Transação" : "Editar Transação"} +

+ +
+ +
+ {/* Auto-fill Section */} +
+
+
+ +
+ handleFotSearch(e.target.value)} + onBlur={() => setTimeout(() => setShowFotSuggestions(false), 200)} + /> + + {showFotSuggestions && fotResults && fotResults.length > 0 && ( +
+ {fotResults.map(f => ( +
selectFot(f)} + > +
FOT: {f.fot}
+
{f.curso_nome} | {f.empresa_nome}
+
{f.ano_formatura_label}
+
+ ))} +
+ )} +
+
+ {fotLoading && Buscando...} +
+ {fotFound && ( +
+
+ Curso: {formData.curso} + Inst: {formData.instituicao} + Ano: {formData.anoFormatura} +
+ {fotEvents.length > 0 && ( +
+

Eventos encontrados:

+
+ {fotEvents.map(ev => ( + + ))} +
+
+ )} +
+ )} +
- {apiError && ( -
- -
-

- Aviso -

-

- {apiError} -

-
+ {/* Data */} +
+ + setFormData({...formData, data: e.target.value})} + /> +
+ + {/* Tipo Evento */} +
+ + +
+ + {/* Tipo Serviço */} +
+ + +
+ + {/* Professional Info */} +
+ + handleProSearch(e.target.value)} + onBlur={() => setTimeout(() => setShowProSuggestions(false), 200)} // Delay to allow click + placeholder="Digite para buscar..." + /> + {showProSuggestions && proResults && proResults.length > 0 && ( +
+ {proResults.map(p => ( +
selectProfessional(p)} + > + {p.nome} ({p.funcao_nome}) +
+ ))} +
+ )} +
+
+ + setFormData({...formData, whatsapp: e.target.value})} + /> +
+
+ + setFormData({...formData, cpf: e.target.value})} + /> +
+ + {/* Values */} +
+ + {proFunctions.length > 0 ? ( + + ) : ( + setFormData({...formData, tabelaFree: e.target.value})} + /> + )} +
+
+ + setFormData({...formData, valorFree: parseFloat(e.target.value)})} + /> +
+
+ + setFormData({...formData, valorExtra: parseFloat(e.target.value)})} + /> +
+ +
+ + setFormData({...formData, descricaoExtra: e.target.value})} + /> +
+ + {/* Payment */} +
+ + setFormData({...formData, dataPgto: e.target.value})} + /> +
+
+ +
+
+ Total a Pagar + + R$ {formData.totalPagar?.toFixed(2)} + +
+
+ +
+ + +
- )} - -
-
- {/* FOT */} -
- - { - const value = e.target.value; - if (value.length <= 5) { - setFormData({ ...formData, fot: parseInt(value) || 0 }); - } - }} - max={99999} - className="w-full px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - placeholder="Máx. 5 dígitos" - /> -
- - {/* Data */} -
- - - setFormData({ ...formData, data: e.target.value }) - } - className="w-full px-3 sm:px-4 py-1.5 sm:py-2 text-xs sm:text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Curso */} -
- - -
- - {/* Instituição */} -
- - -
- - {/* Ano Formatura */} -
- - - setFormData({ - ...formData, - anoFormatura: parseInt(e.target.value), - }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Empresa */} -
- - -
- - {/* Tipo Evento */} -
- - -
- - {/* Tipo de Serviço */} -
- - -
- - {/* Nome */} -
- - - setFormData({ ...formData, nome: e.target.value }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Endereço */} -
- - - setFormData({ ...formData, endereco: e.target.value }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* WhatsApp */} -
- - - setFormData({ ...formData, whatsapp: e.target.value }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - placeholder="(00) 00000-0000" - /> -
- - {/* CPF */} -
- - - setFormData({ ...formData, cpf: e.target.value }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - placeholder="000.000.000-00" - /> -
- - {/* Tabela Free */} -
- - - setFormData({ ...formData, tabelaFree: e.target.value }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Valor Free */} -
- - - setFormData({ - ...formData, - valorFree: parseFloat(e.target.value) || 0, - }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Valor Extra */} -
- - - setFormData({ - ...formData, - valorExtra: parseFloat(e.target.value) || 0, - }) - } - className="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-brand-gold focus:border-transparent" - /> -
- - {/* Descrição do Extra */} -
- -