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";