From 63821454426d68d6f199681be217ad75d1e3d737 Mon Sep 17 00:00:00 2001
From: NANDO9322
Date: Tue, 10 Feb 2026 20:34:33 -0300
Subject: [PATCH] =?UTF-8?q?feat(backend):=20implementa=20cria=C3=A7=C3=A3o?=
=?UTF-8?q?=20autom=C3=A1tica=20de=20transa=C3=A7=C3=A3o=20financeira=20ao?=
=?UTF-8?q?=20aceitar=20evento?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/cmd/api/main.go | 5 +-
backend/internal/agenda/service.go | 99 +++++++++++++--
backend/internal/db/generated/agenda.sql.go | 27 ++++
.../db/generated/tipos_eventos.sql.go | 2 +-
backend/internal/db/queries/agenda.sql | 6 +-
backend/internal/db/queries/tipos_eventos.sql | 2 +-
frontend/pages/Dashboard.tsx | 116 +++++++++++++++++-
frontend/pages/Finance.tsx | 24 ++--
frontend/services/apiService.ts | 25 ++++
9 files changed, 285 insertions(+), 21 deletions(-)
diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go
index 4da8c27..8c08998 100644
--- a/backend/cmd/api/main.go
+++ b/backend/cmd/api/main.go
@@ -73,6 +73,7 @@ func main() {
// Initialize services
notificationService := notification.NewService()
profissionaisService := profissionais.NewService(queries)
+ financeService := finance.NewService(queries, profissionaisService)
authService := auth.NewService(queries, profissionaisService, cfg)
funcoesService := funcoes.NewService(queries)
cursosService := cursos.NewService(queries)
@@ -81,7 +82,7 @@ func main() {
tiposServicosService := tipos_servicos.NewService(queries)
tiposEventosService := tipos_eventos.NewService(queries)
cadastroFotService := cadastro_fot.NewService(queries)
- agendaService := agenda.NewService(queries, notificationService, cfg)
+ agendaService := agenda.NewService(queries, notificationService, cfg, financeService)
availabilityService := availability.NewService(queries)
s3Service := storage.NewS3Service(cfg)
@@ -105,7 +106,7 @@ func main() {
escalasHandler := escalas.NewHandler(escalas.NewService(queries))
logisticaHandler := logistica.NewHandler(logistica.NewService(queries, notificationService, cfg))
codigosHandler := codigos.NewHandler(codigos.NewService(queries))
- financeHandler := finance.NewHandler(finance.NewService(queries, profissionaisService))
+ financeHandler := finance.NewHandler(financeService)
r := gin.Default()
diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go
index 089345c..e7b6a61 100644
--- a/backend/internal/agenda/service.go
+++ b/backend/internal/agenda/service.go
@@ -14,21 +14,25 @@ import (
"photum-backend/internal/db/generated"
"photum-backend/internal/notification"
+ "photum-backend/internal/finance"
+
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type Service struct {
- queries *generated.Queries
- notification *notification.Service
- cfg *config.Config
+ queries *generated.Queries
+ notification *notification.Service
+ financeService *finance.Service
+ cfg *config.Config
}
-func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config) *Service {
+func NewService(db *generated.Queries, notif *notification.Service, cfg *config.Config, fin *finance.Service) *Service {
return &Service{
- queries: db,
- notification: notif,
- cfg: cfg,
+ queries: db,
+ notification: notif,
+ cfg: cfg,
+ financeService: fin,
}
}
@@ -751,6 +755,87 @@ func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professi
return fmt.Errorf("conflito de horário: profissional já confirmou presença em outro evento às %s", c.Horario.String)
}
}
+
+ }
+
+ // --- AUTO-CREATE FINANCIAL TRANSACTION ---
+ // 1. Fetch Assignment to know the Function/Role
+ assign, err := s.queries.GetAssignment(ctx, generated.GetAssignmentParams{
+ AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true},
+ ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
+ })
+ if err != nil {
+ log.Printf("[AutoFinance] Error fetching assignment: %v", err)
+ } else {
+ // 2. Determine Service Name
+ var serviceName string
+ if assign.FuncaoID.Valid {
+ fn, err := s.queries.GetFuncaoByID(ctx, assign.FuncaoID)
+ if err == nil {
+ serviceName = fn.Nome
+ }
+ }
+
+ // Fallback to professional's default function
+ prof, errProf := s.queries.GetProfissionalByID(ctx, generated.GetProfissionalByIDParams{
+ ID: pgtype.UUID{Bytes: professionalID, Valid: true},
+ Regiao: pgtype.Text{String: regiao, Valid: true},
+ })
+ if serviceName == "" && errProf == nil && prof.FuncaoProfissionalID.Valid {
+ fn, err := s.queries.GetFuncaoByID(ctx, prof.FuncaoProfissionalID)
+ if err == nil {
+ serviceName = fn.Nome
+ }
+ }
+
+ // 3. Fetch Event Type Name
+ tipoEvento, errType := s.queries.GetTipoEventoByID(ctx, generated.GetTipoEventoByIDParams{
+ ID: agenda.TipoEventoID,
+ Regiao: pgtype.Text{String: regiao, Valid: true},
+ })
+
+ if serviceName != "" && errType == nil && errProf == nil {
+ // 4. Fetch Standard Price
+ price, errPrice := s.financeService.GetStandardPrice(ctx, tipoEvento.Nome, serviceName, regiao)
+
+ baseValue := 0.0
+ var priceNumeric pgtype.Numeric
+ if errPrice == nil {
+ priceNumeric = price
+ if val, err := price.Float64Value(); err == nil && val.Valid {
+ baseValue = val.Float64
+ }
+ } else {
+ log.Printf("[AutoFinance] Price not found for %s / %s: %v", tipoEvento.Nome, serviceName, errPrice)
+ // We continue with 0.0 or handle error?
+ // User wants it to appear. If 0, it appears as 0.
+ priceNumeric.Scan("0")
+ }
+
+ // 5. Create Transaction
+ // Check if already exists? (Optional, but good practice. For now simpler is better)
+
+ _, errCreate := s.financeService.Create(ctx, generated.CreateTransactionParams{
+ FotID: agenda.FotID,
+ DataCobranca: agenda.DataEvento,
+ TipoEvento: pgtype.Text{String: tipoEvento.Nome, Valid: true},
+ TipoServico: pgtype.Text{String: serviceName, Valid: true},
+ ProfessionalName: pgtype.Text{String: prof.Nome, Valid: true},
+ Whatsapp: prof.Whatsapp,
+ Cpf: prof.CpfCnpjTitular,
+ TotalPagar: priceNumeric, // Base Value
+ ValorFree: priceNumeric, // Populate Free value for frontend calc
+ ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true},
+ PgtoOk: pgtype.Bool{Bool: false, Valid: true},
+ // Others default/null
+ }, regiao)
+
+ if errCreate != nil {
+ log.Printf("[AutoFinance] Failed to create transaction: %v", errCreate)
+ } else {
+ log.Printf("[AutoFinance] Transaction created for %s: %.2f", prof.Nome, baseValue)
+ }
+ }
}
}
}
diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go
index 0251d4a..45ba2f5 100644
--- a/backend/internal/db/generated/agenda.sql.go
+++ b/backend/internal/db/generated/agenda.sql.go
@@ -427,6 +427,33 @@ func (q *Queries) GetAgendaProfessionals(ctx context.Context, agendaID pgtype.UU
return items, nil
}
+const getAssignment = `-- name: GetAssignment :one
+SELECT id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em, is_coordinator FROM agenda_profissionais
+WHERE agenda_id = $1 AND profissional_id = $2
+`
+
+type GetAssignmentParams struct {
+ AgendaID pgtype.UUID `json:"agenda_id"`
+ ProfissionalID pgtype.UUID `json:"profissional_id"`
+}
+
+func (q *Queries) GetAssignment(ctx context.Context, arg GetAssignmentParams) (AgendaProfissionai, error) {
+ row := q.db.QueryRow(ctx, getAssignment, arg.AgendaID, arg.ProfissionalID)
+ var i AgendaProfissionai
+ err := row.Scan(
+ &i.ID,
+ &i.AgendaID,
+ &i.ProfissionalID,
+ &i.Status,
+ &i.MotivoRejeicao,
+ &i.FuncaoID,
+ &i.Posicao,
+ &i.CriadoEm,
+ &i.IsCoordinator,
+ )
+ return i, err
+}
+
const listAgendas = `-- name: ListAgendas :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, a.logistica_notificacao_enviada_em, a.regiao, a.contatos,
diff --git a/backend/internal/db/generated/tipos_eventos.sql.go b/backend/internal/db/generated/tipos_eventos.sql.go
index aedbe6f..d6f3d1b 100644
--- a/backend/internal/db/generated/tipos_eventos.sql.go
+++ b/backend/internal/db/generated/tipos_eventos.sql.go
@@ -118,7 +118,7 @@ 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 AND te.regiao = $3
+WHERE te.nome ILIKE $1 AND f.nome ILIKE $2 AND te.regiao = $3
LIMIT 1
`
diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql
index e9a0834..1651555 100644
--- a/backend/internal/db/queries/agenda.sql
+++ b/backend/internal/db/queries/agenda.sql
@@ -255,4 +255,8 @@ ORDER BY a.data_evento;
-- name: SetCoordinator :exec
UPDATE agenda_profissionais
SET is_coordinator = $3
-WHERE agenda_id = $1 AND profissional_id = $2;
\ No newline at end of file
+WHERE agenda_id = $1 AND profissional_id = $2;
+
+-- name: GetAssignment :one
+SELECT * FROM agenda_profissionais
+WHERE agenda_id = $1 AND profissional_id = $2;
diff --git a/backend/internal/db/queries/tipos_eventos.sql b/backend/internal/db/queries/tipos_eventos.sql
index 37993bc..2a800b3 100644
--- a/backend/internal/db/queries/tipos_eventos.sql
+++ b/backend/internal/db/queries/tipos_eventos.sql
@@ -37,7 +37,7 @@ 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 AND te.regiao = @regiao
+WHERE te.nome ILIKE $1 AND f.nome ILIKE $2 AND te.regiao = @regiao
LIMIT 1;
-- name: GetTipoEventoByNome :one
diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx
index 2123952..b1785f1 100644
--- a/frontend/pages/Dashboard.tsx
+++ b/frontend/pages/Dashboard.tsx
@@ -22,7 +22,7 @@ import {
AlertCircle,
Star,
} from "lucide-react";
-import { setCoordinator, finalizeFOT } from "../services/apiService";
+import { setCoordinator, finalizeFOT, getPrice } from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { useData } from "../contexts/DataContext";
import { STATUS_COLORS } from "../constants";
@@ -151,6 +151,97 @@ export const Dashboard: React.FC = ({
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
const [roleSelectionProf, setRoleSelectionProf] = useState(null);
+ const [basePrice, setBasePrice] = useState(null);
+
+ useEffect(() => {
+ const fetchBasePrice = async () => {
+ if (!selectedEvent || !user || !token) {
+ setBasePrice(null);
+ return;
+ }
+
+ // Only for professionals (skip owners/admins unless they want to see it? User asked for professional view)
+ // If user is admin but viewing as professional context? No, user req specifically: "ao convidar um profissional... no painel dele"
+ if (user.role === UserRole.EVENT_OWNER || user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) {
+ setBasePrice(null);
+ return;
+ }
+
+ const currentProf = professionals.find(p => p.usuarioId === user.id);
+ if (!currentProf) return;
+
+ // Determine Service Name
+ let serviceName = "";
+
+ // 1. Check assignment
+ const assignment = selectedEvent.assignments?.find(a => a.professionalId === currentProf.id);
+ if (assignment && assignment.funcaoId && functions) {
+ const fn = functions.find(f => f.id === assignment.funcaoId);
+ if (fn) serviceName = fn.nome;
+ }
+
+ // 2. Fallback to professional functions/role
+ if (!serviceName) {
+ if (currentProf.functions && currentProf.functions.length > 0) {
+ // Use first function as default base? Or try to match?
+ // Simple approach: use first.
+ serviceName = currentProf.functions[0].nome;
+ } else {
+ serviceName = currentProf.role;
+ }
+ }
+
+ // Map legacy roles if needed (Backend expects names matching tipos_servicos)
+ // e.g. "fotografo" -> "Fotografia"? Check backend migration/seeds.
+ // Assuming names match or backend handles fuzzy match.
+ // Backend GetStandardPrice uses ILIKE on tipos_servicos.nome.
+ // "Fotógrafo" might need to map to "Fotografia".
+ // "Cinegrafista" -> "Cinegrafia".
+ // Let's try raw first, but maybe normalize if needed.
+ // Actually, let's map common ones just in case.
+ // Map legacy roles and English enums
+ const serviceLower = serviceName.toLowerCase();
+ if (serviceLower.includes("fot") || serviceLower === "photographer") serviceName = "Fotógrafo";
+ else if (serviceLower.includes("cine") || serviceLower === "videographer") serviceName = "Cinegrafista";
+ else if (serviceLower.includes("recep")) serviceName = "Recepcionista";
+ else if (serviceLower === "drone") serviceName = "Drone"; // Example
+
+ console.log("Fetch Base Price Debug:", {
+ currentProfId: currentProf.id,
+ assignmentFound: !!assignment,
+ rawService: serviceLower,
+ finalService: serviceName,
+ eventType: selectedEvent.type
+ });
+
+ if (serviceName && selectedEvent.type) {
+ try {
+ const res = await getPrice(token, selectedEvent.type, serviceName);
+ console.log("Fetch Base Price Result:", res);
+ if (res.data) {
+ setBasePrice(res.data.valor);
+ } else {
+ console.warn("Base Price returned no data");
+ setBasePrice(0);
+ }
+ } catch (err) {
+ console.error("Error fetching base price:", err);
+ setBasePrice(0);
+ }
+ } else {
+ console.warn("Skipping price fetch: Missing serviceName or eventType");
+ }
+ };
+
+
+ fetchBasePrice();
+ }, [selectedEvent, user, token, professionals, functions]);
+
+ // ... (existing code) ...
+
+ // Inside render (around line 1150 in original file, need to find the "Time" card)
+ // Look for: ... Horário ...
+
useEffect(() => {
if (initialView) {
@@ -1023,6 +1114,17 @@ export const Dashboard: React.FC = ({
{selectedEvent.time}
+
+ {basePrice !== null && (
+ 0 ? "bg-green-50 border-green-200" : "bg-gray-50 border-gray-200"}`}>
+
0 ? "text-green-700" : "text-gray-500"}`}>
+ Valor Base
+
+
0 ? "text-green-800" : "text-gray-700"}`}>
+ {basePrice > 0 ? basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) : "R$ 0,00"}
+
+
+ )}
{/* FOT Information Table */}
@@ -1309,6 +1411,18 @@ export const Dashboard: React.FC = ({
>
)}
+ {/* Base Value Card for Professionals */}
+ {basePrice !== null && basePrice > 0 && (
+
+ |
+ Valor Base
+ |
+
+ {basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })}
+ |
+
+ )}
+
|
Horário
diff --git a/frontend/pages/Finance.tsx b/frontend/pages/Finance.tsx
index e5c1f94..edee60e 100644
--- a/frontend/pages/Finance.tsx
+++ b/frontend/pages/Finance.tsx
@@ -544,7 +544,7 @@ const Finance: React.FC = () => {
whatsapp: t.whatsapp,
cpf: t.cpf,
tabelaFree: t.tabelaFree,
- valorFree: t.valorFree,
+ valorFree: t.valorFree ?? t.totalPagar, // Use Total as fallback if Free is null (legacy/auto records)
valorExtra: t.valorExtra,
descricaoExtra: t.descricaoExtra,
dataPgto: t.dataPgto,
@@ -1074,8 +1074,8 @@ const Finance: React.FC = () => {
| {t.whatsapp} |
{t.cpf} |
{t.tabelaFree} |
- {t.valorFree?.toFixed(2)} |
- {t.valorExtra?.toFixed(2)} |
+ {t.valorFree != null ? t.valorFree.toFixed(2) : "-"} |
+ {t.valorExtra != null ? t.valorExtra.toFixed(2) : "-"} |
{t.descricaoExtra} |
{t.totalPagar?.toFixed(2)} |
@@ -1292,13 +1292,13 @@ const Finance: React.FC = () => {
/>
- {/* Values */}
+ {/* Values */}
{proFunctions.length > 0 ? (
) : (
setFormData({...formData, tabelaFree: e.target.value})}
+ value={formData.tabelaFree || ""} onChange={e => setFormData({...formData, tabelaFree: e.target.value})}
/>
)}
setFormData({...formData, valorFree: parseFloat(e.target.value)})}
+ value={formData.valorFree ?? ""}
+ onChange={e => {
+ const val = e.target.value;
+ setFormData({...formData, valorFree: val === "" ? null : parseFloat(val)});
+ }}
/>
setFormData({...formData, valorExtra: parseFloat(e.target.value)})}
+ value={formData.valorExtra ?? ""}
+ onChange={e => {
+ const val = e.target.value;
+ setFormData({...formData, valorExtra: val === "" ? null : parseFloat(val)});
+ }}
/>
diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts
index 31612fc..0c75b29 100644
--- a/frontend/services/apiService.ts
+++ b/frontend/services/apiService.ts
@@ -1391,6 +1391,31 @@ export async function getProfessionalFinancialStatement(token: string): Promise<
}
}
+/**
+ * Busca o preço base para um dado evento e serviço
+ */
+export async function getPrice(token: string, eventName: string, serviceName: string): Promise> {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/finance/price?event=${encodeURIComponent(eventName)}&service=${encodeURIComponent(serviceName)}`, {
+ method: "GET",
+ headers: {
+ "Content-Type": "application/json",
+ "Authorization": `Bearer ${token}`,
+ "x-regiao": localStorage.getItem("photum_selected_region") || "SP"
+ },
+ });
+
+ if (!response.ok) {
+ throw new Error(`HTTP error! status: ${response.status}`);
+ }
+ const data = await response.json();
+ return { data, error: null, isBackendDown: false };
+ } catch (error) {
+ console.error("Error fetching price:", error);
+ return { data: null, error: error instanceof Error ? error.message : "Erro desconhecido", isBackendDown: true };
+ }
+}
+
export const setCoordinator = async (token: string, eventId: string, professionalId: string, isCoordinator: boolean) => {
try {
const region = localStorage.getItem("photum_selected_region") || "SP";
|