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:
commit
1b55707f90
16 changed files with 602 additions and 414 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 != '';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
260
frontend/pages/ProfessionalStatement.tsx
Normal file
260
frontend/pages/ProfessionalStatement.tsx
Normal 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;
|
||||
|
|
@ -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
7
frontend/utils/format.ts
Normal 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);
|
||||
};
|
||||
Loading…
Reference in a new issue