feat: implementa função de coordenador de eventos

- Adiciona coluna `is_coordinator` na tabela `agenda_profissionais`
- Atualiza queries SQL e gera código com sqlc
- Implementa endpoint `PUT /api/agenda/:id/professionals/:profId/coordinator`
- Adiciona ícone de estrela no Dashboard para definir coordenadores
- Restringe acesso à aba de Logística apenas para coordenadores e admins
This commit is contained in:
NANDO9322 2026-02-09 22:33:21 -03:00
parent 050c164286
commit 1ba9499074
14 changed files with 228 additions and 32 deletions

View file

@ -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)

View file

@ -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)
}

View file

@ -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},

View file

@ -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
}

View file

@ -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 {

View file

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

View file

@ -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),
@ -242,3 +245,8 @@ 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;
-- name: SetCoordinator :exec
UPDATE agenda_profissionais
SET is_coordinator = $3
WHERE agenda_id = $1 AND profissional_id = $2;

View file

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

View file

@ -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);

View file

@ -611,6 +611,7 @@ interface DataContextType {
updateEventDetails: (id: string, data: any) => Promise<void>;
functions: { id: string; nome: string }[];
isLoading: boolean;
refreshEvents: () => Promise<void>;
}
const DataContext = createContext<DataContextType | undefined>(undefined);
@ -630,6 +631,7 @@ export const DataProvider: React.FC<{ children: ReactNode }> = ({
const [pendingUsers, setPendingUsers] = useState<User[]>([]);
const [professionals, setProfessionals] = useState<Professional[]>([]);
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}

View file

@ -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<DashboardProps> = ({
updateEventDetails,
functions,
isLoading,
refreshEvents,
} = useData();
// ... (inside component)
@ -244,6 +247,30 @@ export const Dashboard: React.FC<DashboardProps> = ({
};
};
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<DashboardProps> = ({
</td>
</tr>
{/* 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)) && (
<>
<tr className="bg-blue-50">
<td colSpan={2} className="px-4 py-3 text-xs font-bold text-blue-700 uppercase tracking-wider">
@ -1419,17 +1446,28 @@ export const Dashboard: React.FC<DashboardProps> = ({
onClick={() => handleViewProfessional(photographer!)}
>
<div
className="w-8 h-8 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
className="relative w-8 h-8 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
style={{
backgroundImage: `url(${photographer?.avatar ||
`https://i.pravatar.cc/100?u=${assignment.professionalId}`
})`,
backgroundSize: "cover",
}}
></div>
>
{assignment.is_coordinator && (
<div className="absolute -top-1 -right-1 bg-white rounded-full p-0.5 shadow-sm border border-gray-100">
<Star size={10} className="text-yellow-500 fill-yellow-500" />
</div>
)}
</div>
<div className="flex flex-col">
<span className="text-gray-700 font-medium">
{photographer?.name || "Fotógrafo"}
{assignment.is_coordinator && (
<span className="ml-1 text-xs text-yellow-600 font-bold border border-yellow-200 bg-yellow-50 px-1 rounded">
Coord.
</span>
)}
</span>
<span className="text-xs text-gray-500">
{assignment.status === "PENDENTE" ? "Convite Pendente" : "Confirmado"}
@ -1683,22 +1721,31 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Profissional */}
<td className="p-4 cursor-pointer" onClick={() => handleViewProfessional(photographer)}>
<div className="flex items-center gap-3">
<div className="relative">
{/* Avatar */}
<div
className="w-10 h-10 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
style={{
className="w-10 h-10 rounded-full border border-gray-200 bg-gray-300 flex-shrink-0"
style={{
backgroundImage: `url(${photographer.avatar})`,
backgroundSize: "cover",
}}
backgroundPosition: "center",
}}
/>
<div>
<p className="font-semibold text-gray-900">
{photographer.name}
</p>
<p className="text-xs text-gray-500">
ID: {photographer.id}
</p>
</div>
</div>
{assignment?.is_coordinator && (
<div className="absolute -top-1 -right-1 bg-white rounded-full p-0.5 shadow-sm border border-gray-100">
<Star size={12} className="text-yellow-500 fill-yellow-500" />
</div>
)}
</div>
<div>
<p className="font-semibold text-gray-800 text-sm">
{photographer.name || photographer.nome}
</p>
<p className="text-xs text-gray-500">
ID: {photographer.id.substring(0, 8)}...
</p>
</div>
</div>
</td>
{/* Função */}
@ -1751,6 +1798,7 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Ação */}
<td className="p-4 text-center">
<div className="flex items-center justify-center gap-2">
<button
onClick={() =>
togglePhotographer(photographer.id)
@ -1764,6 +1812,19 @@ export const Dashboard: React.FC<DashboardProps> = ({
{isProcessing && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin"></div>}
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"}
</button>
{(status === "ACEITO" || status === "PENDENTE") && (
<button
onClick={(e) => {
e.stopPropagation();
handleSetCoordinator(photographer.id, !!assignment?.is_coordinator);
}}
className={`p-2 rounded-full hover:bg-gray-100 transition-colors ${assignment?.is_coordinator ? "bg-yellow-50" : ""}`}
title={assignment?.is_coordinator ? "Remover coordenação" : "Definir como coordenador"}
>
<Star size={18} className={assignment?.is_coordinator ? "text-yellow-500 fill-yellow-500" : "text-gray-400"} />
</button>
)}
</div>
</td>
</tr>
);
@ -1818,15 +1879,22 @@ export const Dashboard: React.FC<DashboardProps> = ({
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
<div className="flex items-center gap-3 mb-3" onClick={() => handleViewProfessional(photographer)}>
<div
className="w-12 h-12 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
className="relative w-12 h-12 rounded-full border-2 border-gray-200 bg-gray-300 flex-shrink-0"
style={{
backgroundImage: `url(${photographer.avatar})`,
backgroundSize: "cover",
}}
/>
>
{/* Visual Indicator of Coordinator in the list */}
{assignment?.is_coordinator && (
<div className="absolute top-0 right-0 p-1">
<Star size={12} className="text-yellow-500 fill-yellow-500" />
</div>
)}
</div>
<div>
<h4 className="font-bold text-gray-900">{photographer.name || photographer.nome}</h4>
<p className="text-xs text-gray-500">ID: {photographer.id.substring(0, 8)}...</p>
<p className="font-medium text-gray-800 text-sm">{photographer.name || photographer.nome}</p>
<p className="text-xs text-gray-500">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
</div>
</div>

View file

@ -23,7 +23,8 @@ const EventDetails: React.FC = () => {
if (!event) return <div className="p-8 text-center text-red-500">Evento não encontrado.</div>;
// 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();

View file

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

View file

@ -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 {