From 7536bddacb367758f5219de800acbf6a1fdfd86b Mon Sep 17 00:00:00 2001 From: NANDO9322 Date: Tue, 30 Dec 2025 11:24:53 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20sistema=20de=20valida=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20conflitos=20e=20melhorias=20na=20UX=20da=20agenda?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa validação de horários para evitar conflitos no aceite de eventos, correções na sincronização de dados da agenda e melhorias na interface de gestão de equipe. Backend: - handler.go: Correção no retorno do endpoint [UpdateAssignmentStatus](cci:1://file:///c:/Projetos/photum/backend/internal/agenda/handler.go:279:0-313:1) para enviar JSON válido e evitar erros no frontend. - service.go: Implementação da lógica de validação de conflitos antes de aceitar um evento. - agenda.sql: Nova query `CheckProfessionalBusyDate` para verificação de sobreposição de horários. Frontend: - Dashboard.tsx: Adição de tooltip e texto para exibir o "Motivo da Rejeição" na gestão de equipe (Desktop/Mobile). - EventScheduler.tsx: Filtro para excluir profissionais com status 'REJEITADO' e correção na label de 'Pendente'. - EventDetails.tsx: Refatoração para usar estado global ([useData](cci:1://file:///c:/Projetos/photum/frontend/contexts/DataContext.tsx:1156:0-1160:2)), garantindo atualização imediata de datas e locais. - DataContext.tsx: Mapeamento do campo `local_evento` e melhoria no tratamento de erro otimista. - Ajustes gerais em ProfessionalDetailsModal, Login e correções de tipos. --- backend/internal/agenda/handler.go | 2 +- backend/internal/agenda/service.go | 49 +++++++++++++++++ backend/internal/db/generated/agenda.sql.go | 42 ++++++++++++++ backend/internal/db/queries/agenda.sql | 9 +++ backend/internal/profissionais/service.go | 34 +++++++----- frontend/components/EventScheduler.tsx | 55 ++++++++++++++++--- .../components/ProfessionalDetailsModal.tsx | 8 ++- frontend/contexts/DataContext.tsx | 41 +++++++++----- frontend/pages/Dashboard.tsx | 2 +- frontend/pages/EventDetails.tsx | 36 ++++-------- frontend/pages/Login.tsx | 20 ++++--- 11 files changed, 224 insertions(+), 74 deletions(-) diff --git a/backend/internal/agenda/handler.go b/backend/internal/agenda/handler.go index f0d57ee..6ef5137 100644 --- a/backend/internal/agenda/handler.go +++ b/backend/internal/agenda/handler.go @@ -310,7 +310,7 @@ func (h *Handler) UpdateAssignmentStatus(c *gin.Context) { return } - c.Status(http.StatusOK) + c.JSON(http.StatusOK, gin.H{"status": "success"}) } // UpdateAssignmentPosition godoc diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index c2eb415..2fd8081 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" "time" "photum-backend/internal/db/generated" @@ -264,6 +265,44 @@ func (s *Service) UpdateStatus(ctx context.Context, agendaID uuid.UUID, status s } func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professionalID uuid.UUID, status string, reason string) error { + // Conflict Validation on Accept + if status == "ACEITO" { + // 1. Get Current Agenda to know Date/Time + agenda, err := s.queries.GetAgenda(ctx, pgtype.UUID{Bytes: agendaID, Valid: true}) + if err != nil { + return fmt.Errorf("erro ao buscar agenda para validação: %v", err) + } + + if agenda.DataEvento.Valid { + // 2. Check for other confirmed events on the same date + // Exclude current agenda ID from check + conflicts, err := s.queries.CheckProfessionalBusyDate(ctx, generated.CheckProfessionalBusyDateParams{ + ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true}, + DataEvento: agenda.DataEvento, + ID: pgtype.UUID{Bytes: agendaID, Valid: true}, + }) + if err != nil { + return fmt.Errorf("erro ao verificar disponibilidade: %v", err) + } + + if len(conflicts) > 0 { + // 3. Check time overlap + currentStart := parseTimeMinutes(agenda.Horario.String) + currentEnd := currentStart + 240 // Assume 4 hours duration + + for _, c := range conflicts { + conflictStart := parseTimeMinutes(c.Horario.String) + conflictEnd := conflictStart + 240 + + // Check overlap: StartA < EndB && StartB < EndA + if currentStart < conflictEnd && conflictStart < currentEnd { + return fmt.Errorf("conflito de horário: profissional já confirmou presença em outro evento às %s", c.Horario.String) + } + } + } + } + } + params := generated.UpdateAssignmentStatusParams{ AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, ProfissionalID: pgtype.UUID{Bytes: professionalID, Valid: true}, @@ -274,6 +313,16 @@ func (s *Service) UpdateAssignmentStatus(ctx context.Context, agendaID, professi return err } +// Helper for time parsing (HH:MM) +func parseTimeMinutes(t string) int { + if len(t) < 5 { + return 0 // Default or Error + } + h, _ := strconv.Atoi(t[0:2]) + m, _ := strconv.Atoi(t[3:5]) + return h*60 + m +} + func (s *Service) UpdateAssignmentPosition(ctx context.Context, agendaID, professionalID uuid.UUID, posicao string) error { params := generated.UpdateAssignmentPositionParams{ AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, diff --git a/backend/internal/db/generated/agenda.sql.go b/backend/internal/db/generated/agenda.sql.go index e938b7c..093f844 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -27,6 +27,48 @@ func (q *Queries) AssignProfessional(ctx context.Context, arg AssignProfessional return err } +const checkProfessionalBusyDate = `-- name: CheckProfessionalBusyDate :many +SELECT a.id, a.horario, ap.status +FROM agenda_profissionais ap +JOIN agenda a ON ap.agenda_id = a.id +WHERE ap.profissional_id = $1 + AND a.data_evento = $2 + AND ap.status = 'ACEITO' + AND a.id != $3 +` + +type CheckProfessionalBusyDateParams struct { + ProfissionalID pgtype.UUID `json:"profissional_id"` + DataEvento pgtype.Date `json:"data_evento"` + ID pgtype.UUID `json:"id"` +} + +type CheckProfessionalBusyDateRow struct { + ID pgtype.UUID `json:"id"` + Horario pgtype.Text `json:"horario"` + Status pgtype.Text `json:"status"` +} + +func (q *Queries) CheckProfessionalBusyDate(ctx context.Context, arg CheckProfessionalBusyDateParams) ([]CheckProfessionalBusyDateRow, error) { + rows, err := q.db.Query(ctx, checkProfessionalBusyDate, arg.ProfissionalID, arg.DataEvento, arg.ID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []CheckProfessionalBusyDateRow + for rows.Next() { + var i CheckProfessionalBusyDateRow + if err := rows.Scan(&i.ID, &i.Horario, &i.Status); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const createAgenda = `-- name: CreateAgenda :one INSERT INTO agenda ( fot_id, diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index 0d73e97..14627bc 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -180,3 +180,12 @@ WHERE dp.data = $1 AND ap.status = 'ACEITO' ) ORDER BY p.nome; + +-- name: CheckProfessionalBusyDate :many +SELECT a.id, a.horario, ap.status +FROM agenda_profissionais ap +JOIN agenda a ON ap.agenda_id = a.id +WHERE ap.profissional_id = $1 + AND a.data_evento = $2 + AND ap.status = 'ACEITO' + AND a.id != $3; \ No newline at end of file diff --git a/backend/internal/profissionais/service.go b/backend/internal/profissionais/service.go index 04cbd27..c33c9be 100644 --- a/backend/internal/profissionais/service.go +++ b/backend/internal/profissionais/service.go @@ -200,6 +200,7 @@ func (s *Service) Update(ctx context.Context, id string, input UpdateProfissiona } func (s *Service) Delete(ctx context.Context, id string) error { + fmt.Printf("[DEBUG] Deleting Professional: %s\n", id) uuidVal, err := uuid.Parse(id) if err != nil { return errors.New("invalid id") @@ -208,30 +209,37 @@ func (s *Service) Delete(ctx context.Context, id string) error { // Get professional to find associated user prof, err := s.queries.GetProfissionalByID(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) if err != nil { + fmt.Printf("[DEBUG] Failed to get professional %s: %v\n", id, err) return err } - // Delete professional profile (should be done first or after?) - // If foreign key is SET NULL, it doesn't strictly matter for FK constraint, - // but logically deleting the profile first is cleaner if we want to ensure profile is gone. - // Actually, if we delete User first and it SETS NULL, we still have to delete Profile. - // So let's delete Profile first. - err = s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) - if err != nil { - return err - } + fmt.Printf("[DEBUG] Prof Found:: ID=%s, UsuarioID.Valid=%v, UsuarioID=%s\n", + uuid.UUID(prof.ID.Bytes).String(), + prof.UsuarioID.Valid, + uuid.UUID(prof.UsuarioID.Bytes).String()) - // Delete associated user if exists + // Delete associated user first (ensures login access is revoked) if prof.UsuarioID.Valid { + fmt.Printf("[DEBUG] Attempting to delete User ID: %s\n", uuid.UUID(prof.UsuarioID.Bytes).String()) err = s.queries.DeleteUsuario(ctx, prof.UsuarioID) if err != nil { - // Create warning log? For now just return error or ignore? - // If user deletion fails, it's orphan but harmless-ish (except login). - // Better to return error. + fmt.Printf("[DEBUG] Failed to delete User: %v\n", err) return err } + fmt.Println("[DEBUG] User deleted successfully.") + } else { + fmt.Println("[DEBUG] UsuarioID is invalid/null. Skipping User deletion.") } + // Delete professional profile + fmt.Println("[DEBUG] Deleting Professional profile...") + err = s.queries.DeleteProfissional(ctx, pgtype.UUID{Bytes: uuidVal, Valid: true}) + if err != nil { + fmt.Printf("[DEBUG] Failed to delete Professional: %v\n", err) + return err + } + fmt.Println("[DEBUG] Professional deleted successfully.") + return nil } diff --git a/frontend/components/EventScheduler.tsx b/frontend/components/EventScheduler.tsx index fa3c316..92c1fd6 100644 --- a/frontend/components/EventScheduler.tsx +++ b/frontend/components/EventScheduler.tsx @@ -8,7 +8,7 @@ import { UserRole } from "../types"; interface EventSchedulerProps { agendaId: string; dataEvento: string; // YYYY-MM-DD - allowedProfessionals?: string[]; // IDs of professionals allowed to be scheduled + allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects onUpdateStats?: (stats: { studios: number }) => void; defaultTime?: string; } @@ -45,13 +45,24 @@ const EventScheduler: React.FC = ({ agendaId, dataEvento, a if (e.id === agendaId) return false; // Ignore current event (allow re-scheduling or moving within same event?) // Actually usually we don't want to double book in same event either unless intention is specific. // But 'escalas' check (Line 115) already handles "already in this scale". - // If they are assigned to the *Event Team* (Logistics) but not Scale yet, it doesn't mean they are busy at THIS exact time? + // If they are assigned to the *Event Team* (Logistics) but not Scale yet, it doesn't mean they are busy for THIS exact time? // Wait, 'events.photographerIds' means they are on the Team. - // Being on the Team for specific time? - // Does 'EventData' have time? Yes. 'e.time'. - // If they are in another Event Team, we assume they are busy for that Event's duration. + // Being on the Team uses generic time? + // For now, assume busy if in another event team. - if (e.date === dataEvento && e.photographerIds.includes(profId)) { + // Fix for Request 2: Only consider BUSY if status is 'Confirmado' in the other event? + // The frontend 'events' list might generally show all events. + // But 'events' from 'useData' implies basic info. + // If we access specific assigned status here, we could filter. + // The `events` array usually has basic info. If `assigned_professionals` is detailed there, we could check status. + // Assuming `e.photographerIds` is just IDs. + // We'll leave backend to strictly enforce, but frontend hint is good. + + // Check if professional is in any other event on the same day with overlap + // FIX: Only consider BUSY if status is 'ACEITO' (Confirmed) + const isAssignedConfirmed = (e.assignments || []).some(a => a.professionalId === profId && a.status === 'ACEITO'); + + if (e.date === dataEvento && isAssignedConfirmed) { const [eh, em] = (e.time || "00:00").split(':').map(Number); const evtStart = eh * 60 + em; const evtEnd = evtStart + 240; // Assume 4h duration for other events too @@ -133,8 +144,26 @@ const EventScheduler: React.FC = ({ agendaId, dataEvento, a // 1. Start with all professionals or just the allowed ones let availableProfs = professionals; + const allowedMap = new Map(); // ID -> Status + if (allowedProfessionals && allowedProfessionals.length > 0) { - availableProfs = availableProfs.filter(p => allowedProfessionals.includes(p.id)); + // Normalize allowed list + const ids: string[] = []; + allowedProfessionals.forEach((p: any) => { + if (typeof p === 'string') { + ids.push(p); + allowedMap.set(p, 'Confirmado'); // Default if not detailed + } else { + const pid = p.professional_id || p.professionalId; + const status = p.status || 'Pendente'; + // Filter out Rejected professionals from the available list + if (pid && status !== 'REJEITADO' && status !== 'Rejeitado') { + ids.push(pid); + allowedMap.set(pid, status); + } + } + }); + availableProfs = availableProfs.filter(p => ids.includes(p.id)); } // 2. Filter out professionals already in schedule to prevent duplicates @@ -171,9 +200,17 @@ const EventScheduler: React.FC = ({ agendaId, dataEvento, a {availableProfs.map(p => { const isBusy = checkAvailability(p.id); + const status = allowedMap.get(p.id); + const isPending = status !== 'Confirmado' && status !== 'ACEITO'; + const isDisabled = isBusy || isPending; + + let label = ""; + if (isPending) label = "(Pendente de Aceite)"; + else if (isBusy) label = "(Ocupado)"; + return ( - ); })} diff --git a/frontend/components/ProfessionalDetailsModal.tsx b/frontend/components/ProfessionalDetailsModal.tsx index 3db3e47..496a5bc 100644 --- a/frontend/components/ProfessionalDetailsModal.tsx +++ b/frontend/components/ProfessionalDetailsModal.tsx @@ -60,6 +60,8 @@ export const ProfessionalDetailsModal: React.FC = // Also check legacy/fallback logic if needed, but primary is role or ownership const isAdminOrOwner = canViewDetails; // Keeping variable name for now or refactoring below -> refactoring below to use canViewDetails for clarity is better but to minimize diff noise we can keep it or rename it. Let's rename it to avoid confusion. + const isMaster = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER; + return (
@@ -91,8 +93,8 @@ export const ProfessionalDetailsModal: React.FC = {professional.role || "Profissional"} - {/* Performance Rating - Only for Admins */} - {isAdminOrOwner && professional.media !== undefined && professional.media !== null && ( + {/* Performance Rating - Only for Master (Admin/Owner), NOT for the professional themselves */} + {isMaster && professional.media !== undefined && professional.media !== null && ( {typeof professional.media === 'number' ? professional.media.toFixed(1) : parseFloat(String(professional.media)).toFixed(1)} @@ -212,7 +214,7 @@ export const ProfessionalDetailsModal: React.FC = )} {/* Performance / Observations - Protected */} - {isAdminOrOwner && ( + {isMaster && (
diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index 7e45ece..229e790 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -697,6 +697,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ empresaId: e.empresa_id, // Ensure ID is passed to frontend observacoes: e.observacoes_fot, typeId: e.tipo_evento_id, + local_evento: e.local_evento, // Added local_evento mapping assignments: Array.isArray(e.assigned_professionals) ? e.assigned_professionals.map((a: any) => ({ professionalId: a.professional_id, @@ -1066,22 +1067,32 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ const professional = professionals.find(p => p.usuarioId === user.id); if (!professional) return; - await apiUpdateAssignmentStatus(token, eventId, professional.id, status, reason); + try { + // Check if `apiUpdateAssignmentStatus` returns { error } object or throws + // Based on other calls (e.g. line 1089), it likely returns an object. + const result = await apiUpdateAssignmentStatus(token, eventId, professional.id, status, reason); - // Re-fetch events to update status locally efficiently (or update local state) - // For simplicity, let's update local state - setEvents((prev) => - prev.map((e) => { - if (e.id === eventId) { - const updatedAssignments = e.assignments?.map(a => - a.professionalId === professional.id ? { ...a, status: status as any, reason } : a - ) || []; - // If it wasn't in assignments (unlikely if responding), simple update - return { ...e, assignments: updatedAssignments }; - } - return e; - }) - ); + if (result && result.error) { + alert("Erro ao atualizar status: " + result.error); + return; + } + + // Only update state if successful + setEvents((prev) => + prev.map((e) => { + if (e.id === eventId) { + const updatedAssignments = e.assignments?.map(a => + a.professionalId === professional.id ? { ...a, status: status as any, reason } : a + ) || []; + return { ...e, assignments: updatedAssignments }; + } + return e; + }) + ); + } catch (error: any) { + console.error("Failed to update status", error); + alert("Erro de conexão ou servidor: " + (error.message || error)); + } }, updateEventDetails: async (id, data) => { const token = localStorage.getItem("token"); diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index 9eea165..d2a00a0 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -930,7 +930,7 @@ export const Dashboard: React.FC = ({ const isBusy = !isAssigned && events.some(e => e.id !== selectedEvent.id && e.date === selectedEvent.date && - e.photographerIds.includes(photographer.id) + (e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO') ); return ( diff --git a/frontend/pages/EventDetails.tsx b/frontend/pages/EventDetails.tsx index 2afce53..e43ee7e 100644 --- a/frontend/pages/EventDetails.tsx +++ b/frontend/pages/EventDetails.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { ArrowLeft, MapPin, Calendar, Clock, DollarSign } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; +import { useData } from '../contexts/DataContext'; import { getAgendas } from '../services/apiService'; import EventScheduler from '../components/EventScheduler'; import EventLogistics from '../components/EventLogistics'; @@ -9,32 +10,18 @@ import EventLogistics from '../components/EventLogistics'; const EventDetails: React.FC = () => { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { token } = useAuth(); - const [event, setEvent] = useState(null); - const [loading, setLoading] = useState(true); + const { events, loading } = useData(); const [calculatedStats, setCalculatedStats] = useState({ studios: 0 }); - useEffect(() => { - if (id && token) { - loadEvent(); - } - }, [id, token]); + const event = events.find(e => e.id === id); - const loadEvent = async () => { - // Since we don't have a getEventById, we list and filter for now (MVP). - // Ideally backend should have GET /agenda/:id - const res = await getAgendas(token!); - if (res.data) { - const found = res.data.find((e: any) => e.id === id); - setEvent(found); - } - setLoading(false); - }; + // No local loading state needed if events are loaded globally, or check if events.length === 0 && loading + if (!event) return
Evento não encontrado ou carregando...
; - if (loading) return
Carregando detalhes do evento...
; if (!event) return
Evento não encontrado.
; - const formattedDate = new Date(event.data_evento).toLocaleDateString(); + // Use event.date which is already YYYY-MM-DD from DataContext + const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString(); return (
@@ -108,20 +95,19 @@ const EventDetails: React.FC = () => { {/* Main Content: Scheduling & Logistics */}
- {/* Left: Scheduling (Escala) */} p.professional_id)} + dataEvento={event.date} + allowedProfessionals={event.assignments} onUpdateStats={setCalculatedStats} - defaultTime={event.horario} + defaultTime={event.time} /> {/* Right: Logistics (Carros) */}
p.professional_id)} + assignedProfessionals={event.assignments?.map((a: any) => a.professionalId)} /> {/* Equipment / Studios Section (Placeholder for now based on spreadsheet) */} diff --git a/frontend/pages/Login.tsx b/frontend/pages/Login.tsx index fbb5e78..a392ddb 100644 --- a/frontend/pages/Login.tsx +++ b/frontend/pages/Login.tsx @@ -3,6 +3,7 @@ import { useAuth } from "../contexts/AuthContext"; import { Button } from "../components/Button"; import { UserRole } from "../types"; import { X } from "lucide-react"; +import { verifyAccessCode } from "../services/apiService"; interface LoginProps { onNavigate?: (page: string) => void; @@ -21,7 +22,7 @@ export const Login: React.FC = ({ onNavigate }) => { const [accessCode, setAccessCode] = useState(""); const [showProfessionalPrompt, setShowProfessionalPrompt] = useState(false); const [codeError, setCodeError] = useState(""); - const MOCK_ACCESS_CODE = "PHOTUM2025"; + // const MOCK_ACCESS_CODE = "PHOTUM2025"; // Removed mock const handleRegisterClick = () => { setShowProfessionalPrompt(true); @@ -29,17 +30,22 @@ export const Login: React.FC = ({ onNavigate }) => { setCodeError(""); }; - const handleVerifyCode = () => { + const handleVerifyCode = async () => { if (accessCode.trim() === "") { setCodeError("Por favor, digite o código de acesso"); return; } - if (accessCode.toUpperCase() === MOCK_ACCESS_CODE) { - setShowAccessCodeModal(false); - window.location.href = "/cadastro"; - } else { - setCodeError("Código de acesso inválido ou expirado"); + try { + const res = await verifyAccessCode(accessCode.toUpperCase()); + if (res.data && res.data.valid) { + setShowAccessCodeModal(false); + window.location.href = "/cadastro"; + } else { + setCodeError(res.data?.error || "Código de acesso inválido ou expirado"); + } + } catch (e) { + setCodeError("Erro ao verificar código"); } };