Merge pull request #43 from rede5/Front-back-integracao-task19

feat(financeiro): implementação do extrato financeiro do profissional e melhorias na agenda
This commit is contained in:
Andre F. Rodrigues 2026-01-16 16:09:32 -03:00 committed by GitHub
commit 1b55707f90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 602 additions and 414 deletions

View file

@ -167,6 +167,8 @@ func main() {
profGroup.GET("/:id", profissionaisHandler.Get)
profGroup.PUT("/:id", profissionaisHandler.Update)
profGroup.DELETE("/:id", profissionaisHandler.Delete)
// Rota de extrato financeiro (usando agendaHandler por conveniência de serviço)
profGroup.GET("/me/financial-statement", agendaHandler.GetProfessionalFinancialStatement)
}
funcoesGroup := api.Group("/funcoes")

View file

@ -389,3 +389,30 @@ func (h *Handler) ListAvailableProfessionals(c *gin.Context) {
c.JSON(http.StatusOK, profs)
}
// GetProfessionalFinancialStatement godoc
// @Summary Get professional financial statement
// @Description Get financial statement for the logged-in professional
// @Tags agenda
// @Accept json
// @Produce json
// @Success 200 {object} FinancialStatementResponse
// @Failure 401 {object} map[string]string
// @Failure 500 {object} map[string]string
// @Router /api/professionals/me/financial-statement [get]
func (h *Handler) GetProfessionalFinancialStatement(c *gin.Context) {
userIDStr := c.GetString("userID")
userID, err := uuid.Parse(userIDStr)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Usuário não autenticado"})
return
}
statement, err := h.service.GetProfessionalFinancialStatement(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao buscar extrato: " + err.Error()})
return
}
c.JSON(http.StatusOK, statement)
}

View file

@ -587,3 +587,124 @@ func (s *Service) UpdateAssignmentPosition(ctx context.Context, agendaID, profes
func (s *Service) ListAvailableProfessionals(ctx context.Context, date time.Time) ([]generated.ListAvailableProfessionalsForDateRow, error) {
return s.queries.ListAvailableProfessionalsForDate(ctx, pgtype.Date{Time: date, Valid: true})
}
type FinancialStatementResponse struct {
TotalRecebido float64 `json:"total_recebido"`
PagamentosConfirmados float64 `json:"pagamentos_confirmados"`
PagamentosPendentes float64 `json:"pagamentos_pendentes"`
Transactions []FinancialTransactionDTO `json:"transactions"`
}
type FinancialTransactionDTO struct {
ID uuid.UUID `json:"id"`
DataEvento string `json:"data_evento"`
NomeEvento string `json:"nome_evento"` // Fot + Tipo
TipoEvento string `json:"tipo_evento"`
Empresa string `json:"empresa"`
ValorRecebido float64 `json:"valor_recebido"`
ValorFree float64 `json:"valor_free"`
ValorExtra float64 `json:"valor_extra"`
DescricaoExtra string `json:"descricao_extra"`
DataPagamento string `json:"data_pagamento"`
Status string `json:"status"`
}
func (s *Service) GetProfessionalFinancialStatement(ctx context.Context, userID uuid.UUID) (*FinancialStatementResponse, error) {
// 1. Identificar o profissional logado
prof, err := s.queries.GetProfissionalByUsuarioID(ctx, pgtype.UUID{Bytes: userID, Valid: true})
if err != nil {
return &FinancialStatementResponse{}, fmt.Errorf("profissional não encontrado para este usuário")
}
profID := pgtype.UUID{Bytes: prof.ID.Bytes, Valid: true}
cpf := prof.CpfCnpjTitular.String // Fallback para registros antigos
// 2. Buscar Transações
rawTransactions, err := s.queries.ListTransactionsByProfessional(ctx, generated.ListTransactionsByProfessionalParams{
ProfissionalID: profID,
Column2: cpf,
})
if err != nil {
// Se não houver transações, retorna vazio sem erro
return &FinancialStatementResponse{}, nil
}
// 3. Processar e Somar
var response FinancialStatementResponse
var dtoList []FinancialTransactionDTO
for _, t := range rawTransactions {
// Validar valores
valor := 0.0
valTotal, _ := t.TotalPagar.Float64Value() // pgtype.Numeric
if valTotal.Valid {
valor = valTotal.Float64
}
fmt.Printf("DEBUG Transaction %v: Total=%.2f Free=%.2f Extra=%.2f Desc=%s\n",
t.ID, valor, getFloat64(t.ValorFree), getFloat64(t.ValorExtra), t.DescricaoExtra.String)
// Status e Somatórios
status := "Pendente"
if t.PgtoOk.Valid && t.PgtoOk.Bool {
status = "Pago"
response.TotalRecebido += valor
response.PagamentosConfirmados += valor // Assumindo Recebido = Confirmado neste contexto
} else {
response.PagamentosPendentes += valor
}
// Formatar Dados
dataEvento := ""
if t.DataCobranca.Valid {
dataEvento = t.DataCobranca.Time.Format("02/01/2006")
}
dtPagamento := "-"
if t.DataPagamento.Valid {
dtPagamento = t.DataPagamento.Time.Format("02/01/2006")
}
empresa := "-"
if t.EmpresaNome.Valid {
empresa = t.EmpresaNome.String
}
nomeEvento := ""
if t.FotNumero.Valid {
if t.CursoNome.Valid {
nomeEvento = fmt.Sprintf("Formatura %s (FOT %d)", t.CursoNome.String, t.FotNumero.Int32)
} else {
nomeEvento = fmt.Sprintf("Formatura FOT %d", t.FotNumero.Int32)
}
} else {
nomeEvento = t.TipoEvento.String
}
dto := FinancialTransactionDTO{
ID: uuid.UUID(t.ID.Bytes),
DataEvento: dataEvento,
NomeEvento: nomeEvento,
TipoEvento: t.TipoEvento.String,
Empresa: empresa,
ValorRecebido: valor,
ValorFree: getFloat64(t.ValorFree),
ValorExtra: getFloat64(t.ValorExtra),
DescricaoExtra: t.DescricaoExtra.String,
DataPagamento: dtPagamento,
Status: status,
}
dtoList = append(dtoList, dto)
}
response.Transactions = dtoList
return &response, nil
}
func getFloat64(n pgtype.Numeric) float64 {
v, _ := n.Float64Value()
if v.Valid {
return v.Float64
}
return 0
}

View file

@ -18,7 +18,7 @@ INSERT INTO financial_transactions (
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
) RETURNING id, fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name, whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra, total_pagar, data_pagamento, pgto_ok, criado_em, atualizado_em, profissional_id
`
type CreateTransactionParams struct {
@ -74,6 +74,7 @@ func (q *Queries) CreateTransaction(ctx context.Context, arg CreateTransactionPa
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
)
return i, err
}
@ -88,7 +89,7 @@ func (q *Queries) DeleteTransaction(ctx context.Context, id pgtype.UUID) error {
}
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
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, profissional_id FROM financial_transactions WHERE id = $1
`
func (q *Queries) GetTransaction(ctx context.Context, id pgtype.UUID) (FinancialTransaction, error) {
@ -112,12 +113,13 @@ func (q *Queries) GetTransaction(ctx context.Context, id pgtype.UUID) (Financial
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
)
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
SELECT t.id, t.fot_id, t.data_cobranca, t.tipo_evento, t.tipo_servico, t.professional_name, t.whatsapp, t.cpf, t.tabela_free, t.valor_free, t.valor_extra, t.descricao_extra, t.total_pagar, t.data_pagamento, t.pgto_ok, t.criado_em, t.atualizado_em, t.profissional_id, f.fot as fot_numero
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
ORDER BY t.data_cobranca DESC
@ -141,6 +143,7 @@ type ListTransactionsRow struct {
PgtoOk pgtype.Bool `json:"pgto_ok"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
FotNumero pgtype.Int4 `json:"fot_numero"`
}
@ -171,6 +174,7 @@ func (q *Queries) ListTransactions(ctx context.Context) ([]ListTransactionsRow,
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
&i.FotNumero,
); err != nil {
return nil, err
@ -184,7 +188,7 @@ func (q *Queries) ListTransactions(ctx context.Context) ([]ListTransactionsRow,
}
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
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, profissional_id FROM financial_transactions
WHERE fot_id = $1
ORDER BY data_cobranca DESC
`
@ -216,6 +220,93 @@ func (q *Queries) ListTransactionsByFot(ctx context.Context, fotID pgtype.UUID)
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listTransactionsByProfessional = `-- name: ListTransactionsByProfessional :many
SELECT t.id, t.fot_id, t.data_cobranca, t.tipo_evento, t.tipo_servico, t.professional_name, t.whatsapp, t.cpf, t.tabela_free, t.valor_free, t.valor_extra, t.descricao_extra, t.total_pagar, t.data_pagamento, t.pgto_ok, t.criado_em, t.atualizado_em, t.profissional_id, f.fot as fot_numero, e.nome as empresa_nome, c.nome as curso_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
WHERE
t.profissional_id = $1
OR (
$2::text <> '' AND
REGEXP_REPLACE(t.cpf, '\D', '', 'g') = REGEXP_REPLACE($2, '\D', '', 'g')
)
ORDER BY t.data_cobranca DESC
`
type ListTransactionsByProfessionalParams struct {
ProfissionalID pgtype.UUID `json:"profissional_id"`
Column2 string `json:"column_2"`
}
type ListTransactionsByProfessionalRow struct {
ID pgtype.UUID `json:"id"`
FotID pgtype.UUID `json:"fot_id"`
DataCobranca pgtype.Date `json:"data_cobranca"`
TipoEvento pgtype.Text `json:"tipo_evento"`
TipoServico pgtype.Text `json:"tipo_servico"`
ProfessionalName pgtype.Text `json:"professional_name"`
Whatsapp pgtype.Text `json:"whatsapp"`
Cpf pgtype.Text `json:"cpf"`
TabelaFree pgtype.Text `json:"tabela_free"`
ValorFree pgtype.Numeric `json:"valor_free"`
ValorExtra pgtype.Numeric `json:"valor_extra"`
DescricaoExtra pgtype.Text `json:"descricao_extra"`
TotalPagar pgtype.Numeric `json:"total_pagar"`
DataPagamento pgtype.Date `json:"data_pagamento"`
PgtoOk pgtype.Bool `json:"pgto_ok"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
FotNumero pgtype.Int4 `json:"fot_numero"`
EmpresaNome pgtype.Text `json:"empresa_nome"`
CursoNome pgtype.Text `json:"curso_nome"`
}
func (q *Queries) ListTransactionsByProfessional(ctx context.Context, arg ListTransactionsByProfessionalParams) ([]ListTransactionsByProfessionalRow, error) {
rows, err := q.db.Query(ctx, listTransactionsByProfessional, arg.ProfissionalID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTransactionsByProfessionalRow
for rows.Next() {
var i ListTransactionsByProfessionalRow
if err := rows.Scan(
&i.ID,
&i.FotID,
&i.DataCobranca,
&i.TipoEvento,
&i.TipoServico,
&i.ProfessionalName,
&i.Whatsapp,
&i.Cpf,
&i.TabelaFree,
&i.ValorFree,
&i.ValorExtra,
&i.DescricaoExtra,
&i.TotalPagar,
&i.DataPagamento,
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
&i.FotNumero,
&i.EmpresaNome,
&i.CursoNome,
); err != nil {
return nil, err
}
@ -248,7 +339,7 @@ UPDATE financial_transactions SET
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
RETURNING id, fot_id, data_cobranca, tipo_evento, tipo_servico, professional_name, whatsapp, cpf, tabela_free, valor_free, valor_extra, descricao_extra, total_pagar, data_pagamento, pgto_ok, criado_em, atualizado_em, profissional_id
`
type UpdateTransactionParams struct {
@ -306,6 +397,7 @@ func (q *Queries) UpdateTransaction(ctx context.Context, arg UpdateTransactionPa
&i.PgtoOk,
&i.CriadoEm,
&i.AtualizadoEm,
&i.ProfissionalID,
)
return i, err
}

View file

@ -174,6 +174,7 @@ type FinancialTransaction struct {
PgtoOk pgtype.Bool `json:"pgto_ok"`
CriadoEm pgtype.Timestamptz `json:"criado_em"`
AtualizadoEm pgtype.Timestamptz `json:"atualizado_em"`
ProfissionalID pgtype.UUID `json:"profissional_id"`
}
type FuncoesProfissionai struct {

View file

@ -0,0 +1,11 @@
-- Add profissional_id column to financial_transactions
ALTER TABLE financial_transactions ADD COLUMN IF NOT EXISTS profissional_id UUID REFERENCES cadastro_profissionais(id);
-- Optional: Try to link existing transactions by CPF (removes non-digits for comparison)
UPDATE financial_transactions ft
SET profissional_id = cp.id
FROM cadastro_profissionais cp
WHERE ft.profissional_id IS NULL
AND REGEXP_REPLACE(ft.cpf, '\D', '', 'g') = REGEXP_REPLACE(cp.cpf_cnpj_titular, '\D', '', 'g')
AND ft.cpf IS NOT NULL
AND ft.cpf != '';

View file

@ -39,3 +39,17 @@ DELETE FROM financial_transactions WHERE id = $1;
-- name: GetTransaction :one
SELECT * FROM financial_transactions WHERE id = $1;
-- name: ListTransactionsByProfessional :many
SELECT t.*, f.fot as fot_numero, e.nome as empresa_nome, c.nome as curso_nome
FROM financial_transactions t
LEFT JOIN cadastro_fot f ON t.fot_id = f.id
LEFT JOIN empresas e ON f.empresa_id = e.id
LEFT JOIN cursos c ON f.curso_id = c.id
WHERE
t.profissional_id = $1
OR (
$2::text <> '' AND
REGEXP_REPLACE(t.cpf, '\D', '', 'g') = REGEXP_REPLACE($2, '\D', '', 'g')
)
ORDER BY t.data_cobranca DESC;

View file

@ -459,7 +459,8 @@ CREATE TABLE IF NOT EXISTS financial_transactions (
data_pagamento DATE,
pgto_ok BOOLEAN DEFAULT FALSE,
criado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW()
atualizado_em TIMESTAMPTZ NOT NULL DEFAULT NOW(),
profissional_id UUID REFERENCES cadastro_profissionais(id)
);
-- Migration to ensure funcao_id exists (Workaround for primitive migration system)

View file

@ -16,7 +16,7 @@ import { ProfessionalRegister } from "./pages/ProfessionalRegister";
import { TeamPage } from "./pages/Team";
import EventDetails from "./pages/EventDetails";
import Finance from "./pages/Finance";
import PhotographerFinance from "./pages/PhotographerFinance";
import { SettingsPage } from "./pages/Settings";
import { CourseManagement } from "./pages/CourseManagement";
import { InspirationPage } from "./pages/Inspiration";
@ -32,6 +32,7 @@ import { verifyAccessCode } from "./services/apiService";
import { Button } from "./components/Button";
import { X } from "lucide-react";
import { ShieldAlert } from "lucide-react";
import ProfessionalStatement from "./pages/ProfessionalStatement";
// Componente de acesso negado
const AccessDenied: React.FC = () => {
@ -684,9 +685,9 @@ const AppContent: React.FC = () => {
<Route
path="/meus-pagamentos"
element={
<ProtectedRoute allowedRoles={[UserRole.PHOTOGRAPHER]}>
<ProtectedRoute allowedRoles={[UserRole.PHOTOGRAPHER, UserRole.SUPERADMIN]}>
<PageWrapper>
<PhotographerFinance />
<ProfessionalStatement />
</PageWrapper>
</ProtectedRoute>
}

View file

@ -114,7 +114,11 @@ const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, assignedProfe
>
<option value="">Selecione ou deixe vazio...</option>
{professionals
.filter(p => p.carro_disponivel)
.filter(p => {
const hasCar = p.carro_disponivel;
const isAssigned = !assignedProfessionals || assignedProfessionals.includes(p.id);
return hasCar && isAssigned;
})
.map(p => (
<option key={p.id} value={p.id}>{p.nomeEventos || p.nome}</option>
))}

View file

@ -150,6 +150,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
// FILTER: Only show professionals with a valid role (Function), matching "Equipe" page logic.
let availableProfs = professionals.filter(p => roles.some(r => r.id === p.funcao_profissional_id));
const allowedMap = new Map<string, string>(); // ID -> Status
const assignedRoleMap = new Map<string, string>(); // ID -> Role Name
if (allowedProfessionals) {
// Normalize allowed list
@ -165,6 +166,11 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
if (pid && status !== 'REJEITADO' && status !== 'Rejeitado') {
ids.push(pid);
allowedMap.set(pid, status);
if (p.funcaoId) {
const r = roles.find(role => role.id === p.funcaoId);
if (r) assignedRoleMap.set(pid, r.nome);
}
}
}
});
@ -209,13 +215,16 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
const isPending = status !== 'Confirmado' && status !== 'ACEITO';
const isDisabled = isBusy || isPending;
const assignedRole = assignedRoleMap.get(p.id);
const displayRole = assignedRole || p.role || "Profissional";
let label = "";
if (isPending) label = "(Pendente de Aceite)";
else if (isBusy) label = "(Ocupado)";
return (
<option key={p.id} value={p.id} disabled={isDisabled} className={isDisabled ? "text-gray-400" : ""}>
{p.nome} - {p.role || "Profissional"} {label}
{p.nome} - {displayRole} {label}
</option>
);
})}
@ -270,6 +279,8 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
// Find professional data again to show equipment in list if needed
// Ideally backend should return it, but for now we look up in global list if available
const profData = professionals.find(p => p.id === item.profissional_id);
const assignedRole = assignedRoleMap.get(item.profissional_id);
const displayRole = assignedRole || profData?.role;
return (
<div key={item.id} className="flex flex-col p-2 hover:bg-gray-50 rounded border-b">
@ -285,6 +296,7 @@ const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, a
<div>
<p className="font-medium text-gray-800">
{item.profissional_nome}
{displayRole && <span className="ml-1 text-xs text-gray-500 font-normal">({displayRole})</span>}
{item.phone && <span className="ml-2 text-xs text-gray-500 font-normal">({item.phone})</span>}
</p>
<p className="text-xs text-gray-500">

View file

@ -28,9 +28,11 @@ const EventDetails: React.FC = () => {
<div className="max-w-7xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<button onClick={() => navigate('/eventos')} className="p-2 hover:bg-gray-200 rounded-full transition-colors">
<ArrowLeft className="w-6 h-6 text-gray-600" />
<button onClick={() => navigate('/eventos')} className="flex items-center gap-2 px-3 py-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-600">
<ArrowLeft className="w-5 h-5" />
<span className="font-medium">Voltar</span>
</button>
<div className="w-px h-8 bg-gray-300 mx-2 hidden sm:block"></div>
<div>
<h1 className="text-2xl font-bold text-gray-800 flex items-center gap-2">
{event.empresa_nome} - {event.tipo_evento_nome}

View file

@ -1,401 +0,0 @@
import React, { useState, useEffect, useMemo } from "react";
import {
Download,
ArrowUpDown,
ArrowUp,
ArrowDown,
AlertCircle,
} from "lucide-react";
interface PhotographerPayment {
id: string;
data: string;
nomeEvento: string;
tipoEvento: string;
empresa: string;
valorRecebido: number;
dataPagamento: string;
statusPagamento: "Pago" | "Pendente" | "Atrasado";
}
type SortField = keyof PhotographerPayment | null;
type SortDirection = "asc" | "desc" | null;
const PhotographerFinance: React.FC = () => {
const [payments, setPayments] = useState<PhotographerPayment[]>([
{
id: "1",
data: "2025-11-15",
nomeEvento: "Formatura Medicina UFPR 2025",
tipoEvento: "Formatura",
empresa: "PhotoPro Studio",
valorRecebido: 1500.0,
dataPagamento: "2025-11-20",
statusPagamento: "Pago",
},
{
id: "2",
data: "2025-11-18",
nomeEvento: "Formatura Direito PUC-PR 2025",
tipoEvento: "Formatura",
empresa: "PhotoPro Studio",
valorRecebido: 1200.0,
dataPagamento: "2025-11-25",
statusPagamento: "Pago",
},
{
id: "3",
data: "2025-12-01",
nomeEvento: "Formatura Engenharia UTFPR 2025",
tipoEvento: "Formatura",
empresa: "Lens & Art",
valorRecebido: 1800.0,
dataPagamento: "2025-12-15",
statusPagamento: "Pendente",
},
]);
const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
const [apiError, setApiError] = useState<string>("");
// Load API data
useEffect(() => {
loadApiData();
}, []);
const loadApiData = async () => {
try {
// TODO: Implementar chamada real da API
// const response = await fetch("http://localhost:3000/api/photographer/payments");
// const data = await response.json();
// setPayments(data);
} catch (error) {
console.error("Erro ao carregar pagamentos:", error);
setApiError(
"Não foi possível carregar os dados da API. Usando dados de exemplo."
);
}
};
// Sorting logic
const handleSort = (field: keyof PhotographerPayment) => {
if (sortField === field) {
if (sortDirection === "asc") {
setSortDirection("desc");
} else if (sortDirection === "desc") {
setSortDirection(null);
setSortField(null);
}
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field: keyof PhotographerPayment) => {
if (sortField !== field) {
return (
<ArrowUpDown
size={16}
className="opacity-0 group-hover:opacity-50 transition-opacity"
/>
);
}
if (sortDirection === "asc") {
return <ArrowUp size={16} className="text-brand-gold" />;
}
if (sortDirection === "desc") {
return <ArrowDown size={16} className="text-brand-gold" />;
}
return (
<ArrowUpDown
size={16}
className="opacity-0 group-hover:opacity-50 transition-opacity"
/>
);
};
const sortedPayments = useMemo(() => {
if (!sortField || !sortDirection) return payments;
return [...payments].sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
let comparison = 0;
if (typeof aValue === "string" && typeof bValue === "string") {
comparison = aValue.localeCompare(bValue);
} else if (typeof aValue === "number" && typeof bValue === "number") {
comparison = aValue - bValue;
} else if (typeof aValue === "boolean" && typeof bValue === "boolean") {
comparison = aValue === bValue ? 0 : aValue ? 1 : -1;
}
return sortDirection === "asc" ? comparison : -comparison;
});
}, [payments, sortField, sortDirection]);
// Export to CSV
const handleExport = () => {
const headers = [
"Data Evento",
"Nome Evento",
"Tipo Evento",
"Empresa",
"Valor Recebido",
"Data Pagamento",
"Status",
];
const csvContent = [
headers.join(","),
...sortedPayments.map((p) =>
[
p.data,
`"${p.nomeEvento}"`,
p.tipoEvento,
p.empresa,
p.valorRecebido.toFixed(2),
p.dataPagamento,
p.statusPagamento,
].join(",")
),
].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `meus_pagamentos_${Date.now()}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// Calculate totals
const totalRecebido = sortedPayments.reduce(
(sum, p) => sum + p.valorRecebido,
0
);
const totalPago = sortedPayments
.filter((p) => p.statusPagamento === "Pago")
.reduce((sum, p) => sum + p.valorRecebido, 0);
const totalPendente = sortedPayments
.filter((p) => p.statusPagamento === "Pendente")
.reduce((sum, p) => sum + p.valorRecebido, 0);
const getStatusBadge = (status: string) => {
const statusColors = {
Pago: "bg-green-100 text-green-800",
Pendente: "bg-yellow-100 text-yellow-800",
Atrasado: "bg-red-100 text-red-800",
};
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
statusColors[status as keyof typeof statusColors] ||
"bg-gray-100 text-gray-800"
}`}
>
{status}
</span>
);
};
return (
<div className="min-h-screen bg-gray-50 pt-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Meus Pagamentos</h1>
<p className="mt-2 text-gray-600">
Visualize todos os pagamentos recebidos pelos eventos fotografados
</p>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Total Recebido</p>
<p className="text-2xl font-bold text-gray-900">
R$ {totalRecebido.toFixed(2)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Pagamentos Confirmados</p>
<p className="text-2xl font-bold text-green-600">
R$ {totalPago.toFixed(2)}
</p>
</div>
<div className="bg-white rounded-lg shadow p-6">
<p className="text-sm text-gray-600 mb-1">Pagamentos Pendentes</p>
<p className="text-2xl font-bold text-yellow-600">
R$ {totalPendente.toFixed(2)}
</p>
</div>
</div>
{/* Main Card */}
<div className="bg-white rounded-lg shadow">
{/* Actions Bar */}
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">
Histórico de Pagamentos
</h2>
<button
onClick={handleExport}
className="flex items-center gap-2 px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 transition-colors"
>
<Download size={20} />
Exportar
</button>
</div>
</div>
{apiError && (
<div className="mx-6 mt-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg flex items-start gap-3">
<AlertCircle
className="text-yellow-600 flex-shrink-0 mt-0.5"
size={20}
/>
<div className="flex-1">
<p className="text-sm font-medium text-yellow-800">Aviso</p>
<p className="text-sm text-yellow-700 mt-1">{apiError}</p>
</div>
</div>
)}
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("data")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Data Evento
{getSortIcon("data")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("nomeEvento")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Nome Evento
{getSortIcon("nomeEvento")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("tipoEvento")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Tipo Evento
{getSortIcon("tipoEvento")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("empresa")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Empresa
{getSortIcon("empresa")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("valorRecebido")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Valor Recebido
{getSortIcon("valorRecebido")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("dataPagamento")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Data Pagamento
{getSortIcon("dataPagamento")}
</button>
</th>
<th className="px-4 py-3 text-left">
<button
onClick={() => handleSort("statusPagamento")}
className="group flex items-center gap-2 text-xs font-medium text-gray-700 hover:text-gray-900"
>
Status
{getSortIcon("statusPagamento")}
</button>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedPayments.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-4 py-8 text-center text-gray-500"
>
Nenhum pagamento encontrado
</td>
</tr>
) : (
sortedPayments.map((payment) => (
<tr
key={payment.id}
className="hover:bg-gray-50 transition-colors"
>
<td className="px-4 py-3 whitespace-nowrap text-sm">
{new Date(payment.data).toLocaleDateString("pt-BR")}
</td>
<td className="px-4 py-3 text-sm">
{payment.nomeEvento}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
{payment.tipoEvento}
</td>
<td className="px-4 py-3 text-sm">{payment.empresa}</td>
<td className="px-4 py-3 whitespace-nowrap text-sm font-medium text-green-600">
R$ {payment.valorRecebido.toFixed(2)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
{new Date(payment.dataPagamento).toLocaleDateString(
"pt-BR"
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-sm">
{getStatusBadge(payment.statusPagamento)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 bg-gray-50">
<p className="text-sm text-gray-600">
Total de pagamentos: {sortedPayments.length}
</p>
</div>
</div>
</div>
</div>
);
};
export default PhotographerFinance;

View file

@ -0,0 +1,260 @@
import React, { useState, useEffect } from "react";
import { useAuth } from "../contexts/AuthContext";
import { getProfessionalFinancialStatement } from "../services/apiService";
import { formatCurrency } from "../utils/format";
interface FinancialTransactionDTO {
id: string;
data_evento: string;
nome_evento: string;
tipo_evento: string;
empresa: string;
valor_recebido: number;
valor_free?: number; // Optional as backend might not have sent it yet if cache/delays
valor_extra?: number;
descricao_extra?: string;
data_pagamento: string;
status: string;
}
interface FinancialStatementResponse {
total_recebido: number;
pagamentos_confirmados: number;
pagamentos_pendentes: number;
transactions: FinancialTransactionDTO[];
}
const ProfessionalStatement: React.FC = () => {
const { user, token } = useAuth();
const [data, setData] = useState<FinancialStatementResponse | null>(null);
const [loading, setLoading] = useState(true);
const [selectedTransaction, setSelectedTransaction] = useState<FinancialTransactionDTO | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
useEffect(() => {
if (token) {
fetchStatement();
} else {
const storedToken = localStorage.getItem("@Auth:token");
if (storedToken) {
fetchStatement(storedToken);
} else {
console.error("No token found");
setLoading(false);
}
}
}, [token]);
const fetchStatement = async (overrideToken?: string) => {
try {
const t = overrideToken || token;
if (!t) return;
const response = await getProfessionalFinancialStatement(t);
if (response.data) {
setData(response.data);
} else {
console.error(response.error);
}
} catch (error) {
console.error("Erro ao buscar extrato:", error);
} finally {
setLoading(false);
}
};
const handleRowClick = (t: FinancialTransactionDTO) => {
setSelectedTransaction(t);
setIsModalOpen(true);
};
const StatusBadge = ({ status }: { status: string }) => {
const isPaid = status === "Pago";
return (
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
isPaid
? "bg-green-100 text-green-700"
: "bg-yellow-100 text-yellow-700"
}`}
>
{status}
</span>
);
};
if (loading) {
return <div className="p-8 text-center text-gray-500">Carregando extrato...</div>;
}
if (!data) {
return <div className="p-8 text-center text-gray-500">Nenhum dado encontrado.</div>;
}
return (
<div className="bg-gray-50 min-h-screen p-4 sm:p-8 font-sans">
<div className="max-w-7xl mx-auto">
<header className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Meus Pagamentos</h1>
<p className="text-gray-500">
Visualize todos os pagamentos recebidos pelos eventos fotografados
</p>
</header>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
<p className="text-sm text-gray-500 mb-1">Total Recebido</p>
<p className="text-2xl font-bold text-gray-900">
{formatCurrency(data.total_recebido)}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
<p className="text-sm text-gray-500 mb-1">Pagamentos Confirmados</p>
<p className="text-2xl font-bold text-green-600">
{formatCurrency(data.pagamentos_confirmados)}
</p>
</div>
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-100">
<p className="text-sm text-gray-500 mb-1">Pagamentos Pendentes</p>
<p className="text-2xl font-bold text-yellow-600">
{formatCurrency(data.pagamentos_pendentes)}
</p>
</div>
</div>
{/* Transactions Table */}
<div className="bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
<div className="p-6 border-b border-gray-100 flex justify-between items-center">
<h2 className="text-lg font-bold text-gray-900">Histórico de Pagamentos</h2>
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-gray-700 bg-gray-50 hover:bg-gray-100 rounded-md transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
Exportar
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-gray-50 text-gray-500 font-medium">
<tr>
<th className="px-6 py-4">Data Evento</th>
<th className="px-6 py-4">Nome Evento</th>
<th className="px-6 py-4">Tipo Evento</th>
<th className="px-6 py-4">Empresa</th>
<th className="px-6 py-4">Valor Recebido</th>
<th className="px-6 py-4">Data Pagamento</th>
<th className="px-6 py-4 text-center">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{(data.transactions || []).length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-8 text-center text-gray-500">
Nenhum pagamento registrado.
</td>
</tr>
) : (
(data.transactions || []).map((t) => (
<tr
key={t.id}
className="hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => handleRowClick(t)}
>
<td className="px-6 py-4 text-gray-900">{t.data_evento}</td>
<td className="px-6 py-4 text-gray-900 font-medium">{t.nome_evento}</td>
<td className="px-6 py-4 text-gray-500">{t.tipo_evento}</td>
<td className="px-6 py-4 text-gray-500">{t.empresa}</td>
<td className={`px-6 py-4 font-medium ${t.status === 'Pago' ? 'text-green-600' : 'text-green-600'}`}>
{formatCurrency(t.valor_recebido)}
</td>
<td className="px-6 py-4 text-gray-500">{t.data_pagamento}</td>
<td className="px-6 py-4 text-center">
<StatusBadge status={t.status} />
</td>
</tr>
))
)}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<td colSpan={7} className="px-6 py-3 text-xs text-gray-500">
Total de pagamentos: {(data.transactions || []).length}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
{/* Details Modal */}
{isModalOpen && selectedTransaction && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg overflow-hidden animate-in fade-in zoom-in duration-200">
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
<h3 className="text-lg font-bold text-gray-900">Detalhes do Pagamento</h3>
<button
onClick={() => setIsModalOpen(false)}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-full hover:bg-gray-100"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
</svg>
</button>
</div>
<div className="p-6 space-y-6">
{/* Header Info */}
<div>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-1">Evento</p>
<p className="text-xl font-bold text-gray-900">{selectedTransaction.nome_evento}</p>
<p className="text-sm text-gray-600 mt-1">{selectedTransaction.empresa} {selectedTransaction.data_evento}</p>
</div>
{/* Financial Breakdown */}
<div className="bg-gray-50 rounded-xl p-4 space-y-3 border border-gray-100">
<div className="flex justify-between items-center">
<span className="text-gray-600 text-sm">Valor Base (Free)</span>
<span className="font-semibold text-gray-900">{formatCurrency(selectedTransaction.valor_free || 0)}</span>
</div>
{(selectedTransaction.valor_extra || 0) > 0 && (
<div className="flex justify-between items-center">
<span className="text-yellow-700 text-sm font-medium">Valor Extra</span>
<span className="font-semibold text-yellow-700">{formatCurrency(selectedTransaction.valor_extra || 0)}</span>
</div>
)}
{/* Divider */}
<div className="border-t border-gray-200 my-2"></div>
<div className="flex justify-between items-center pt-1">
<span className="text-gray-900 font-bold text-base">Total Recebido</span>
<span className="text-green-600 font-bold text-lg">{formatCurrency(selectedTransaction.valor_recebido)}</span>
</div>
</div>
{/* Extra Description */}
{(selectedTransaction.descricao_extra) && (
<div>
<p className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-2">Descrição do Extra</p>
<div className="bg-yellow-50 text-yellow-800 p-3 rounded-lg text-sm border border-yellow-100">
{selectedTransaction.descricao_extra}
</div>
</div>
)}
{/* Status Footer */}
<div className="flex justify-between items-center pt-2">
<span className="text-sm text-gray-500">Data do Pagamento</span>
<div className="flex gap-3 items-center">
<span className="font-medium text-gray-900">{selectedTransaction.data_pagamento}</span>
<StatusBadge status={selectedTransaction.status} />
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};
export default ProfessionalStatement;

View file

@ -1132,3 +1132,37 @@ export async function listPassengers(carroId: string, token: string) {
export async function verifyAccessCode(code: string): Promise<ApiResponse<{ valid: boolean; error?: string; empresa_id?: string; empresa_nome?: string }>> {
return fetchFromBackend<{ valid: boolean; error?: string; empresa_id?: string; empresa_nome?: string }>(`/api/public/codigos-acesso/verificar?code=${encodeURIComponent(code)}`);
}
/**
* Busca o extrato financeiro do profissional logado
*/
export async function getProfessionalFinancialStatement(token: string): Promise<ApiResponse<any>> {
try {
const response = await fetch(`${API_BASE_URL}/api/profissionais/me/financial-statement`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `HTTP error! status: ${response.status}`);
}
const data = await response.json();
return {
data,
error: null,
isBackendDown: false,
};
} catch (error) {
console.error("Error fetching financial statement:", error);
return {
data: null,
error: error instanceof Error ? error.message : "Erro desconhecido",
isBackendDown: true,
};
}
}

7
frontend/utils/format.ts Normal file
View file

@ -0,0 +1,7 @@
export const formatCurrency = (value: number | undefined | null): string => {
if (value === undefined || value === null) return "R$ 0,00";
return new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
};