diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index 61fb383..1aa4ffa 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -238,6 +238,7 @@ func main() { api.DELETE("/agenda/:id/professionals/:profId", auth.RequireWriteAccess(), agendaHandler.RemoveProfessional) api.PATCH("/agenda/:id/professionals/:profId/status", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentStatus) api.PATCH("/agenda/:id/professionals/:profId/position", auth.RequireWriteAccess(), agendaHandler.UpdateAssignmentPosition) + api.PUT("/agenda/:id/professionals/:profId/coordinator", auth.RequireWriteAccess(), agendaHandler.SetCoordinator) api.PATCH("/agenda/:id/status", auth.RequireWriteAccess(), agendaHandler.UpdateStatus) api.POST("/agenda/:id/notify-logistics", auth.RequireWriteAccess(), agendaHandler.NotifyLogistics) api.POST("/import/agenda", auth.RequireWriteAccess(), agendaHandler.Import) diff --git a/backend/internal/agenda/handler.go b/backend/internal/agenda/handler.go index 7814d35..dac0e65 100644 --- a/backend/internal/agenda/handler.go +++ b/backend/internal/agenda/handler.go @@ -550,3 +550,45 @@ func (h *Handler) Import(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Agenda importada com sucesso"}) } + +// SetCoordinator godoc +// @Summary Set professional as coordinator +// @Tags agenda +// @Router /api/agenda/{id}/professionals/{profId}/coordinator [put] +func (h *Handler) SetCoordinator(c *gin.Context) { + idParam := c.Param("id") + agendaID, err := uuid.Parse(idParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de agenda inválido"}) + return + } + + profIdParam := c.Param("profId") + profID, err := uuid.Parse(profIdParam) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "ID de profissional inválido"}) + return + } + + var req struct { + IsCoordinator bool `json:"is_coordinator"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Dados inválidos: " + err.Error()}) + return + } + + // Security: Block RESEARCHER + if c.GetString("role") == "RESEARCHER" { + c.JSON(http.StatusForbidden, gin.H{"error": "Acesso negado: Somente leitura"}) + return + } + + // regiao := c.GetString("regiao") // Not needed for SetCoordinator + if err := h.service.SetCoordinator(c.Request.Context(), agendaID, profID, req.IsCoordinator); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erro ao definir coordenador: " + err.Error()}) + return + } + + c.Status(http.StatusOK) +} diff --git a/backend/internal/agenda/service.go b/backend/internal/agenda/service.go index 616e4a4..a19bb75 100644 --- a/backend/internal/agenda/service.go +++ b/backend/internal/agenda/service.go @@ -70,6 +70,7 @@ type Assignment struct { Status string `json:"status"` MotivoRejeicao *string `json:"motivo_rejeicao"` FuncaoID *string `json:"funcao_id"` + IsCoordinator bool `json:"is_coordinator"` } type AgendaResponse struct { @@ -415,6 +416,14 @@ func (s *Service) AssignProfessional(ctx context.Context, agendaID uuid.UUID, pr return nil } +func (s *Service) SetCoordinator(ctx context.Context, agendaID, profID uuid.UUID, isCoordinator bool) error { + return s.queries.SetCoordinator(ctx, generated.SetCoordinatorParams{ + AgendaID: pgtype.UUID{Bytes: agendaID, Valid: true}, + ProfissionalID: pgtype.UUID{Bytes: profID, Valid: true}, + IsCoordinator: pgtype.Bool{Bool: isCoordinator, Valid: true}, + }) +} + func (s *Service) RemoveProfessional(ctx context.Context, agendaID uuid.UUID, profID uuid.UUID) error { params := generated.RemoveProfessionalParams{ 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 55e84d6..2108037 100644 --- a/backend/internal/db/generated/agenda.sql.go +++ b/backend/internal/db/generated/agenda.sql.go @@ -443,7 +443,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -579,7 +580,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -823,7 +825,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -1091,6 +1094,23 @@ func (q *Queries) RemoveProfessional(ctx context.Context, arg RemoveProfessional return err } +const setCoordinator = `-- name: SetCoordinator :exec +UPDATE agenda_profissionais +SET is_coordinator = $3 +WHERE agenda_id = $1 AND profissional_id = $2 +` + +type SetCoordinatorParams struct { + AgendaID pgtype.UUID `json:"agenda_id"` + ProfissionalID pgtype.UUID `json:"profissional_id"` + IsCoordinator pgtype.Bool `json:"is_coordinator"` +} + +func (q *Queries) SetCoordinator(ctx context.Context, arg SetCoordinatorParams) error { + _, err := q.db.Exec(ctx, setCoordinator, arg.AgendaID, arg.ProfissionalID, arg.IsCoordinator) + return err +} + const updateAgenda = `-- name: UpdateAgenda :one UPDATE agenda SET @@ -1274,7 +1294,7 @@ const updateAssignmentPosition = `-- name: UpdateAssignmentPosition :one UPDATE agenda_profissionais SET posicao = $3 WHERE agenda_id = $1 AND profissional_id = $2 -RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em, is_coordinator ` type UpdateAssignmentPositionParams struct { @@ -1295,6 +1315,7 @@ func (q *Queries) UpdateAssignmentPosition(ctx context.Context, arg UpdateAssign &i.FuncaoID, &i.Posicao, &i.CriadoEm, + &i.IsCoordinator, ) return i, err } @@ -1303,7 +1324,7 @@ const updateAssignmentStatus = `-- name: UpdateAssignmentStatus :one UPDATE agenda_profissionais SET status = $3, motivo_rejeicao = $4 WHERE agenda_id = $1 AND profissional_id = $2 -RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em +RETURNING id, agenda_id, profissional_id, status, motivo_rejeicao, funcao_id, posicao, criado_em, is_coordinator ` type UpdateAssignmentStatusParams struct { @@ -1330,6 +1351,7 @@ func (q *Queries) UpdateAssignmentStatus(ctx context.Context, arg UpdateAssignme &i.FuncaoID, &i.Posicao, &i.CriadoEm, + &i.IsCoordinator, ) return i, err } diff --git a/backend/internal/db/generated/models.go b/backend/internal/db/generated/models.go index 625a75c..0c817b9 100644 --- a/backend/internal/db/generated/models.go +++ b/backend/internal/db/generated/models.go @@ -62,6 +62,7 @@ type AgendaProfissionai struct { FuncaoID pgtype.UUID `json:"funcao_id"` Posicao pgtype.Text `json:"posicao"` CriadoEm pgtype.Timestamptz `json:"criado_em"` + IsCoordinator pgtype.Bool `json:"is_coordinator"` } type AnosFormatura struct { diff --git a/backend/internal/db/migrations/019_add_coordinator_column.up.sql b/backend/internal/db/migrations/019_add_coordinator_column.up.sql new file mode 100644 index 0000000..21dee85 --- /dev/null +++ b/backend/internal/db/migrations/019_add_coordinator_column.up.sql @@ -0,0 +1,5 @@ +-- Up +ALTER TABLE agenda_profissionais ADD COLUMN IF NOT EXISTS is_coordinator BOOLEAN DEFAULT FALSE; + +-- Down +ALTER TABLE agenda_profissionais DROP COLUMN IF EXISTS is_coordinator; diff --git a/backend/internal/db/queries/agenda.sql b/backend/internal/db/queries/agenda.sql index 9c3948c..b394a4c 100644 --- a/backend/internal/db/queries/agenda.sql +++ b/backend/internal/db/queries/agenda.sql @@ -50,7 +50,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -81,7 +82,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -228,7 +230,8 @@ SELECT 'professional_id', ap.profissional_id, 'status', ap.status, 'motivo_rejeicao', ap.motivo_rejeicao, - 'funcao_id', ap.funcao_id + 'funcao_id', ap.funcao_id, + 'is_coordinator', ap.is_coordinator )) FROM agenda_profissionais ap WHERE ap.agenda_id = a.id), @@ -241,4 +244,9 @@ JOIN empresas e ON cf.empresa_id = e.id JOIN anos_formaturas af ON cf.ano_formatura_id = af.id JOIN tipos_eventos te ON a.tipo_evento_id = te.id WHERE cf.empresa_id = $1 AND a.regiao = @regiao -ORDER BY a.data_evento; \ No newline at end of file +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 diff --git a/backend/internal/db/schema.sql b/backend/internal/db/schema.sql index e29bfe6..2ed27c0 100644 --- a/backend/internal/db/schema.sql +++ b/backend/internal/db/schema.sql @@ -524,3 +524,6 @@ BEGIN DO UPDATE SET valor = EXCLUDED.valor; END $$; + +-- Migration 019: Add Coordinator Column +ALTER TABLE agenda_profissionais ADD COLUMN IF NOT EXISTS is_coordinator BOOLEAN DEFAULT FALSE; diff --git a/frontend/contexts/AuthContext.tsx b/frontend/contexts/AuthContext.tsx index d77209d..590f1ed 100644 --- a/frontend/contexts/AuthContext.tsx +++ b/frontend/contexts/AuthContext.tsx @@ -119,6 +119,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => bairro: backendUser.bairro, cidade: backendUser.cidade, estado: backendUser.estado, + professionalId: data.profissional?.id, // Map professional ID }; console.log("AuthContext: restoreSession mapped user:", mappedUser); if (!backendUser.ativo) { @@ -219,6 +220,7 @@ const login = async (email: string, password?: string) => { bairro: backendUser.bairro, cidade: backendUser.cidade, estado: backendUser.estado, + professionalId: data.profissional?.id, // Map professional ID }; setUser(mappedUser); diff --git a/frontend/contexts/DataContext.tsx b/frontend/contexts/DataContext.tsx index 1a8484a..4e0877a 100644 --- a/frontend/contexts/DataContext.tsx +++ b/frontend/contexts/DataContext.tsx @@ -611,6 +611,7 @@ interface DataContextType { updateEventDetails: (id: string, data: any) => Promise; functions: { id: string; nome: string }[]; isLoading: boolean; + refreshEvents: () => Promise; } const DataContext = createContext(undefined); @@ -630,6 +631,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ const [pendingUsers, setPendingUsers] = useState([]); const [professionals, setProfessionals] = useState([]); const [functions, setFunctions] = useState<{ id: string; nome: string }[]>([]); + const [refreshTrigger, setRefreshTrigger] = useState(0); // Fetch events from API useEffect(() => { @@ -726,7 +728,8 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ professionalId: a.professional_id, status: a.status, reason: a.motivo_rejeicao, - funcaoId: a.funcao_id + funcaoId: a.funcao_id, + is_coordinator: a.is_coordinator })) : [], logisticaNotificacaoEnviadaEm: e.logistica_notificacao_enviada_em, @@ -743,7 +746,11 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ } }; fetchEvents(); - }, [token]); // React to token change + }, [token, refreshTrigger]); // React to token change and manual refresh + + const refreshEvents = async () => { + setRefreshTrigger(prev => prev + 1); + }; // Fetch pending users from API useEffect(() => { @@ -1202,6 +1209,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({ }, functions, isLoading, + refreshEvents, }} > {children} diff --git a/frontend/pages/Dashboard.tsx b/frontend/pages/Dashboard.tsx index 2e4d636..fbb253c 100644 --- a/frontend/pages/Dashboard.tsx +++ b/frontend/pages/Dashboard.tsx @@ -20,7 +20,9 @@ import { UserCheck, UserX, AlertCircle, + Star, } from "lucide-react"; +import { setCoordinator } from "../services/apiService"; import { useAuth } from "../contexts/AuthContext"; import { useData } from "../contexts/DataContext"; import { STATUS_COLORS } from "../constants"; @@ -49,6 +51,7 @@ export const Dashboard: React.FC = ({ updateEventDetails, functions, isLoading, + refreshEvents, } = useData(); // ... (inside component) @@ -244,6 +247,30 @@ export const Dashboard: React.FC = ({ }; }; + const handleSetCoordinator = async (professionalId: string, currentStatus: boolean) => { + if (!user || !selectedEvent) return; + const token = localStorage.getItem("token"); + if (!token) return; + + const res = await setCoordinator(token, selectedEvent.id, professionalId, !currentStatus); + if (res.error) { + alert("Erro ao definir coordenador: " + res.error); + } else { + // Optimistic update + setSelectedEvent(prev => prev ? ({ + ...prev, + assignments: prev.assignments?.map(a => + a.professionalId === professionalId ? { ...a, is_coordinator: !currentStatus } : a + ) + }) : null); + + // Force refresh to get data from server and persist color + setTimeout(() => { + refreshEvents(); + }, 500); + } + }; + // Função para fechar modal de equipe e limpar filtros const closeTeamModal = () => { setIsTeamModalOpen(false); @@ -1036,8 +1063,8 @@ export const Dashboard: React.FC = ({ - {/* Seção de Gestão de Equipe - Ocultar para Fotógrafos */} - {user.role !== UserRole.PHOTOGRAPHER && ( + {/* Seção de Gestão de Equipe - Ocultar para Fotógrafos, mas mostrar para Coordenadores */} + {(user.role !== UserRole.PHOTOGRAPHER || (selectedEvent.assignments || []).some((a: any) => a.professionalId === (user.professionalId || user.id) && a.is_coordinator)) && ( <> @@ -1419,17 +1446,28 @@ export const Dashboard: React.FC = ({ onClick={() => handleViewProfessional(photographer!)} >
+ > + {assignment.is_coordinator && ( +
+ +
+ )} +
{photographer?.name || "Fotógrafo"} + {assignment.is_coordinator && ( + + Coord. + + )} {assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"} @@ -1683,22 +1721,31 @@ export const Dashboard: React.FC = ({ {/* Profissional */} handleViewProfessional(photographer)}>
+
+ {/* Avatar */}
-
-

- {photographer.name} -

-

- ID: {photographer.id} -

-
-
+ {assignment?.is_coordinator && ( +
+ +
+ )} +
+
+

+ {photographer.name || photographer.nome} +

+

+ ID: {photographer.id.substring(0, 8)}... +

+
+
{/* Função */} @@ -1751,6 +1798,7 @@ export const Dashboard: React.FC = ({ {/* Ação */} +
+ {(status === "ACEITO" || status === "PENDENTE") && ( + + )} +
); @@ -1818,15 +1879,22 @@ export const Dashboard: React.FC = ({
handleViewProfessional(photographer)}>
+ > + {/* Visual Indicator of Coordinator in the list */} + {assignment?.is_coordinator && ( +
+ +
+ )} +
-

{photographer.name || photographer.nome}

-

ID: {photographer.id.substring(0, 8)}...

+

{photographer.name || photographer.nome}

+

{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}

diff --git a/frontend/pages/EventDetails.tsx b/frontend/pages/EventDetails.tsx index d687079..2a8166e 100644 --- a/frontend/pages/EventDetails.tsx +++ b/frontend/pages/EventDetails.tsx @@ -23,7 +23,8 @@ const EventDetails: React.FC = () => { if (!event) return
Evento não encontrado.
; // Check if user can view logistics - const canViewLogistics = user?.role !== UserRole.AGENDA_VIEWER && user?.role !== UserRole.RESEARCHER; + const isCoordinator = event?.assignments?.some(a => a.professionalId === (user?.professionalId || user?.id) && a.is_coordinator); + const canViewLogistics = isCoordinator || user?.role === UserRole.SUPERADMIN || user?.role === UserRole.EVENT_OWNER || user?.role === UserRole.BUSINESS_OWNER; // Use event.date which is already YYYY-MM-DD from DataContext const formattedDate = new Date(event.date + "T00:00:00").toLocaleDateString(); diff --git a/frontend/services/apiService.ts b/frontend/services/apiService.ts index b7b3496..e6e5e1c 100644 --- a/frontend/services/apiService.ts +++ b/frontend/services/apiService.ts @@ -1362,3 +1362,27 @@ export async function getProfessionalFinancialStatement(token: string): Promise< }; } } + +export const setCoordinator = async (token: string, eventId: string, professionalId: string, isCoordinator: boolean) => { + try { + const region = localStorage.getItem("photum_selected_region") || "SP"; + const response = await fetch(`${API_BASE_URL}/api/agenda/${eventId}/professionals/${professionalId}/coordinator`, { + method: "PUT", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "x-regiao": region + }, + body: JSON.stringify({ is_coordinator: isCoordinator }), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + return { error: errorData.error || "Failed to set coordinator" }; + } + return { success: true }; + } catch (error) { + console.error("API setCoordinator error:", error); + return { error: "Network error" }; + } +}; diff --git a/frontend/types.ts b/frontend/types.ts index ed9b466..3b71c30 100644 --- a/frontend/types.ts +++ b/frontend/types.ts @@ -49,6 +49,7 @@ export interface User { empresaId?: string; // ID da empresa vinculada (para Business Owners) companyName?: string; // Nome da empresa vinculada allowedRegions?: string[]; // Regiões permitidas para o usuário + professionalId?: string; // ID do profissional vinculado (se houver) // Client / Event Owner specific fields cpf_cnpj?: string; @@ -123,6 +124,7 @@ export interface Assignment { status: AssignmentStatus; reason?: string; funcaoId?: string; + is_coordinator?: boolean; } export interface EventData {