photum/frontend/pages/Dashboard.tsx

2427 lines
118 KiB
TypeScript

import React, { useState, useEffect, useMemo } from "react";
import { useNavigate, useLocation } from 'react-router-dom';
import { UserRole, EventData, EventStatus, EventType, Professional } from "../types";
import { EventTable } from "../components/EventTable";
import { EventFiltersBar, EventFilters } from "../components/EventFiltersBar";
import { EventForm } from "../components/EventForm";
import { Button } from "../components/Button";
import {
PlusCircle,
Search,
CheckCircle,
Clock,
Edit,
Users,
Map,
Building2,
Calendar,
MapPin,
X,
UserCheck,
UserX,
AlertCircle,
Star,
Car, // Added Car icon
} from "lucide-react";
import { setCoordinator, finalizeFOT, getPrice, getDailyInvitationsProfessional, respondDailyInvitation, getDailyInvitationsByDate } from "../services/apiService";
import { useAuth } from "../contexts/AuthContext";
import { useData } from "../contexts/DataContext";
import { STATUS_COLORS } from "../constants";
import { ProfessionalDetailsModal } from "../components/ProfessionalDetailsModal";
interface DashboardProps {
initialView?: "list" | "create";
}
export const Dashboard: React.FC<DashboardProps> = ({
initialView = "list",
}) => {
const { user, token } = useAuth();
const navigate = useNavigate();
const location = useLocation();
// Extract updateEventDetails from useData
const {
events,
getEventsByRole,
addEvent,
updateEventStatus,
assignPhotographer,
professionals,
getInstitutionById,
getActiveCoursesByInstitutionId,
respondToAssignment,
updateEventDetails,
functions,
isLoading,
refreshEvents,
} = useData();
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
const [isLoadingInvites, setIsLoadingInvites] = useState(false);
const fetchDailyInvitations = async () => {
if (user.role !== UserRole.PHOTOGRAPHER && user.role !== UserRole.AGENDA_VIEWER) return;
const currentProf = professionals.find(p => p.usuarioId === user.id);
if (!currentProf) return;
setIsLoadingInvites(true);
try {
const resp = await getDailyInvitationsProfessional(currentProf.id);
setDailyInvitations(resp || []);
} catch (err) {
console.error(err);
} finally {
setIsLoadingInvites(false);
}
};
useEffect(() => {
if (professionals.length > 0) fetchDailyInvitations();
}, [user, professionals]);
const handleRespondDailyInvitation = async (id: string, status: "ACEITO" | "REJEITADO") => {
try {
await respondDailyInvitation(id, status);
fetchDailyInvitations();
} catch (err) {
console.error("Error responding to invitation", err);
}
};
const handleSaveEvent = async (data: any) => {
const isClient = user.role === UserRole.EVENT_OWNER;
if (view === "edit" && selectedEvent) {
if (updateEventDetails) {
await updateEventDetails(selectedEvent.id, data);
// Force reload of view to reflect changes (or rely on DataContext optimistic update)
// But DataContext optimistic update only touched generic fields.
// Address might still be old in 'selectedEvent' state if we don't update it.
// Updating selectedEvent manually as well to be safe, mapping snake_case payload to camelCase state:
const updatedEvent = {
...selectedEvent,
...data,
date: data.date || data.data_evento?.split('T')[0] || selectedEvent.date,
// Map snake_case fields used in EventForm payload to camelCase fields used in UI
qtdFormandos: data.qtd_formandos ?? selectedEvent.qtdFormandos,
qtdFotografos: data.qtd_fotografos ?? selectedEvent.qtdFotografos,
qtdRecepcionistas: data.qtd_recepcionistas ?? selectedEvent.qtdRecepcionistas,
qtdCinegrafistas: data.qtd_cinegrafistas ?? selectedEvent.qtdCinegrafistas,
qtdEstudios: data.qtd_estudios ?? selectedEvent.qtdEstudios,
qtdPontosFoto: data.qtd_pontos_foto ?? selectedEvent.qtdPontosFoto,
qtdPontosDecorados: data.qtd_pontos_decorados ?? selectedEvent.qtdPontosDecorados,
qtdPontosLed: data.qtd_pontos_led ?? selectedEvent.qtdPontosLed,
qtdPlataforma360: data.qtd_plataforma_360 ?? selectedEvent.qtdPlataforma360,
name: data.observacoes_evento || selectedEvent.name,
briefing: data.observacoes_evento || selectedEvent.briefing,
time: data.horario || selectedEvent.time,
};
setSelectedEvent(updatedEvent);
setView("details");
// Reloading window to ensure total consistency with backend as fallback
// window.location.reload(); // Commented out to try SPA update first
} else {
console.error("Update function not available");
}
} else {
const initialStatus = isClient
? EventStatus.PENDING_APPROVAL
: EventStatus.PLANNING;
const newEvent: EventData = {
...data,
id: Math.random().toString(36).substr(2, 9),
status: initialStatus,
checklist: [],
ownerId: isClient ? user.id : "unknown",
photographerIds: [],
};
await addEvent(newEvent);
setView("list");
}
};
const [view, setView] = useState<"list" | "create" | "edit" | "details">(() => {
const params = new URLSearchParams(location.search);
return params.get("eventId") ? "details" : initialView;
});
const [searchTerm, setSearchTerm] = useState("");
const [selectedEvent, setSelectedEvent] = useState<EventData | null>(() => {
const params = new URLSearchParams(location.search);
const eventId = params.get("eventId");
if (eventId && events.length > 0) {
return events.find(e => e.id === eventId) || null;
}
return null;
});
// Effect to sync selectedEvent if events load LATER than initial render
useEffect(() => {
const params = new URLSearchParams(location.search);
const eventId = params.get("eventId");
if (eventId && events.length > 0) {
const found = events.find(e => e.id === eventId);
if (found && (!selectedEvent || selectedEvent.id !== eventId)) {
setSelectedEvent(found);
// Ensure view is details if we just found it
setView("details");
} else if (!found && !isLoading) {
// Se não encontrou o evento e já acabou de carregar do servidor, volta pra lista e limpa a URL
const newParams = new URLSearchParams(location.search);
newParams.delete("eventId");
navigate({ search: newParams.toString() }, { replace: true });
setSelectedEvent(null);
setView("list");
}
} else if (!eventId && selectedEvent) {
setSelectedEvent(null);
setView(initialView);
}
}, [events, location.search, selectedEvent, initialView, isLoading, navigate]);
const [activeFilter, setActiveFilter] = useState<string>("all");
const [advancedFilters, setAdvancedFilters] = useState<EventFilters>({
date: "",
fotId: "",
type: "",
company: "",
institution: "",
});
const [isTeamModalOpen, setIsTeamModalOpen] = useState(false);
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
// Estados para filtros do modal de equipe
const [teamSearchTerm, setTeamSearchTerm] = useState("");
const [teamRoleFilter, setTeamRoleFilter] = useState("all");
const [teamStatusFilter, setTeamStatusFilter] = useState("all");
const [teamAvailabilityFilter, setTeamAvailabilityFilter] = useState("all");
const [showOnlyDailyAccepted, setShowOnlyDailyAccepted] = useState(false);
const [eventDailyInvitations, setEventDailyInvitations] = useState<any[]>([]);
const [roleSelectionProf, setRoleSelectionProf] = useState<Professional | null>(null);
const [basePrice, setBasePrice] = useState<number | null>(null);
// Utility para cores de cidade baseado na planilha do cliente + cores dinâmicas
const getCityColor = (city: string | undefined) => {
if (!city) return 'bg-gray-100 text-gray-700 border-gray-200'; // Default
const c = city.toLowerCase().trim();
if (c.includes('campinas')) return 'bg-pink-100 text-pink-700 border-pink-200';
if (c.includes('piracicaba')) return 'bg-cyan-100 text-cyan-700 border-cyan-200';
if (c.includes('paulo') || c.includes('sp')) return 'bg-slate-200 text-slate-700 border-slate-300';
if (c.includes('americana') || c.includes('sbo') || c.includes('barbara') || c.includes('bárbara') || c.includes('santa barbara')) return 'bg-green-100 text-green-700 border-green-200';
if (c.includes('indaiatuba')) return 'bg-yellow-100 text-yellow-700 border-yellow-200';
// Dynamic color for others
const palette = [
'bg-purple-100 text-purple-700 border-purple-200',
'bg-indigo-100 text-indigo-700 border-indigo-200',
'bg-teal-100 text-teal-700 border-teal-200',
'bg-orange-100 text-orange-700 border-orange-200',
'bg-rose-100 text-rose-700 border-rose-200',
'bg-violet-100 text-violet-700 border-violet-200',
'bg-emerald-100 text-emerald-700 border-emerald-200',
'bg-fuchsia-100 text-fuchsia-700 border-fuchsia-200'
];
let hash = 0;
for (let i = 0; i < c.length; i++) {
hash = c.charCodeAt(i) + ((hash << 5) - hash);
}
return palette[Math.abs(hash) % palette.length];
};
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: <div className="bg-gray-50 p-4 rounded-sm border border-gray-100"> ... Horário ... </div>
useEffect(() => {
const params = new URLSearchParams(location.search);
if (initialView && !params.get("eventId")) {
setView(initialView);
if (initialView === "create") setSelectedEvent(null);
}
}, [initialView, location.search]);
const handleViewProfessional = (professional: Professional) => {
setViewingProfessional(professional);
};
// Função para calcular profissionais faltantes e status
const calculateTeamStatus = (event: EventData) => {
const assignments = event.assignments || [];
// Contadores de profissionais aceitos por tipo
// Helper to check if professional has a specific role
const hasRole = (professional: Professional | undefined, roleSlug: string, assignedFuncaoId?: string) => {
if (!professional) return false;
const term = roleSlug.toLowerCase();
// 1. If assignment has explicit function, check it
if (assignedFuncaoId) {
const fn = functions.find(f => f.id === assignedFuncaoId);
if (fn && fn.nome.toLowerCase().includes(term)) return true;
// If term didn't match the assigned function, return false (exclusive assignment)
// Unless we fallback? No, if assigned as Cine, shouldn't count as Fot.
if (fn) return false;
}
// 2. Fallback to existing logic (capabilities) if no function assigned
// Check functions array first (new multi-role system)
if (professional.functions && professional.functions.length > 0) {
return professional.functions.some(f => f.nome.toLowerCase().includes(term));
}
// Fallback to legacy role field
return (professional.role || "").toLowerCase().includes(term);
};
// Contadores de profissionais aceitos por tipo
const acceptedFotografos = assignments.filter(a =>
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "fot", a.funcaoId)
).length;
const pendingFotografos = assignments.filter(a =>
a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "fot", a.funcaoId)
).length;
const acceptedRecepcionistas = assignments.filter(a =>
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "recep", a.funcaoId)
).length;
const pendingRecepcionistas = assignments.filter(a =>
a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "recep", a.funcaoId)
).length;
const acceptedCinegrafistas = assignments.filter(a =>
a.status === "ACEITO" && hasRole(professionals.find(p => p.id === a.professionalId), "cine", a.funcaoId)
).length;
const pendingCinegrafistas = assignments.filter(a =>
a.status === "PENDENTE" && hasRole(professionals.find(p => p.id === a.professionalId), "cine")
).length;
// Quantidades necessárias
const qtdFotografos = event.qtdFotografos || 0;
const qtdRecepcionistas = event.qtdRecepcionistas || 0;
const qtdCinegrafistas = event.qtdCinegrafistas || 0;
// Calcular faltantes
const fotoFaltante = Math.max(0, qtdFotografos - acceptedFotografos);
const recepFaltante = Math.max(0, qtdRecepcionistas - acceptedRecepcionistas);
const cineFaltante = Math.max(0, qtdCinegrafistas - acceptedCinegrafistas);
// Verificar se todos os profissionais estão OK
const profissionaisOK = fotoFaltante === 0 && recepFaltante === 0 && cineFaltante === 0;
return {
acceptedFotografos,
pendingFotografos,
acceptedRecepcionistas,
pendingRecepcionistas,
acceptedCinegrafistas,
pendingCinegrafistas,
fotoFaltante,
recepFaltante,
cineFaltante,
profissionaisOK,
qtdFotografos,
qtdRecepcionistas,
qtdCinegrafistas
};
};
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);
}
};
useEffect(() => {
const fetchEventDailyInvitations = async () => {
if (isTeamModalOpen && selectedEvent && selectedEvent.date) {
try {
const res = await getDailyInvitationsByDate(selectedEvent.date.split('T')[0]);
setEventDailyInvitations(res || []);
} catch(err) {
console.error("Erro ao buscar convites do dia do evento:", err);
}
}
};
fetchEventDailyInvitations();
}, [isTeamModalOpen, selectedEvent]);
// Função para fechar modal de equipe e limpar filtros
const closeTeamModal = () => {
setIsTeamModalOpen(false);
setTeamSearchTerm("");
setTeamRoleFilter("all");
setTeamStatusFilter("all");
setTeamAvailabilityFilter("all");
setShowOnlyDailyAccepted(false);
};
const [processingIds, setProcessingIds] = useState<Set<string>>(new Set());
const [teamPage, setTeamPage] = useState(1);
const ITEMS_PER_PAGE = 20;
// Optimização 1: Pre-calcular status de ocupação (remove complexidade O(N*M) de dentro do loop)
const busyProfessionalIds = useMemo(() => {
if (!selectedEvent) return new Set<string>();
const busySet = new Set<string>();
// Itera eventos apenas UMA vez
for (const e of events) {
if (e.id !== selectedEvent.id && e.date === selectedEvent.date) {
if (e.assignments) {
for (const a of e.assignments) {
if (a.status === 'ACEITO') {
busySet.add(a.professionalId);
}
}
}
}
}
return busySet;
}, [events, selectedEvent]);
// Função para filtrar profissionais no modal de equipe
const filteredTeamProfessionals = useMemo(() => {
if (!selectedEvent) return professionals;
// Reset page when filters change
setTeamPage(1);
return professionals.filter((professional) => {
// Filter out professionals with unknown roles
if (functions.length > 0) {
const isValidRole = functions.some(f => f.id === professional.funcao_profissional_id);
if (!isValidRole) return false;
}
// Filtro por busca (nome ou email)
if (teamSearchTerm) {
const searchLower = teamSearchTerm.toLowerCase();
const nameMatch = (professional.name || professional.nome || "").toLowerCase().includes(searchLower);
const emailMatch = (professional.email || "").toLowerCase().includes(searchLower);
if (!nameMatch && !emailMatch) return false;
}
// Filtro por função/role
if (teamRoleFilter !== "all") {
const professionalFunctions = professional.functions || [];
const hasMatchingFunction = professionalFunctions.some(f => f.id === teamRoleFilter);
const hasMatchingPrimaryRole = professional.funcao_profissional_id === teamRoleFilter;
if (!hasMatchingFunction && !hasMatchingPrimaryRole) return false;
}
// Verificar assignment no evento atual
// Otimização: Evitar criar array intermediário com filter/find se possível, mas aqui é pequeno (assignments do evento)
const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === professional.id
);
const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO";
// Otimização 2: Usar o Set pré-calculado (O(1))
const isBusy = !isAssigned && busyProfessionalIds.has(professional.id);
// Filtro por status
if (teamStatusFilter !== "all") {
switch (teamStatusFilter) {
case "assigned": if (!isAssigned) return false; break;
case "available": if (isAssigned || isBusy) return false; break;
case "busy": if (!isBusy) return false; break;
case "rejected": if (!status || status !== "REJEITADO") return false; break;
}
}
// Filtro por disponibilidade
if (teamAvailabilityFilter !== "all") {
switch (teamAvailabilityFilter) {
case "available": if (isAssigned || isBusy) return false; break;
case "unavailable": if (!isAssigned && !isBusy) return false; break;
}
}
// Filtro "Confirmados pro Plantão" Exclusivo
if (showOnlyDailyAccepted) {
const hasAcceptedDaily = eventDailyInvitations.some(inv => inv.profissional_id === professional.id && inv.status === 'ACEITO');
if (!hasAcceptedDaily) return false;
}
return true;
});
}, [
selectedEvent,
professionals,
functions,
teamSearchTerm,
teamRoleFilter,
teamStatusFilter,
teamAvailabilityFilter,
eventDailyInvitations,
showOnlyDailyAccepted,
busyProfessionalIds // Depende apenas do Set otimizado
]);
// Paginação
const displayedTeamProfessionals = useMemo(() => {
return filteredTeamProfessionals.slice(0, teamPage * ITEMS_PER_PAGE);
}, [filteredTeamProfessionals, teamPage]);
// Guard Clause for basic security
if (!user)
return <div className="p-10 text-center">Acesso Negado. Faça login.</div>;
const myEvents = getEventsByRole(user.id, user.role);
const currentProfessionalId =
user.role === UserRole.PHOTOGRAPHER
? professionals.find((p) => p.usuarioId === user.id)?.id
: undefined;
// Extract unique values for filters
const { availableTypes, availableCompanies, availableInstitutions } = useMemo(() => {
const types = [...new Set(myEvents.map((e) => e.type))].sort();
const companies = [...new Set(myEvents.map((e) => e.empresa).filter(Boolean))].sort();
const institutions = [...new Set(myEvents.map((e) => e.instituicao).filter(Boolean))].sort();
return {
availableTypes: types,
availableCompanies: companies,
availableInstitutions: institutions,
};
}, [myEvents]);
// Removed the previous useEffect for eventId to avoid conflicts with the new initialization logic
// and separate sync effect above.
/*
// Check for eventId in URL on mount
useEffect(() => {
...
}, [events, window.location.search]);
*/
// Combined filtering logic
const filteredEvents = myEvents.filter((e) => {
const matchesSearch = e.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesStatus =
activeFilter === "all" ||
(activeFilter === "pending" &&
e.status === EventStatus.PENDING_APPROVAL) ||
(activeFilter === "active" &&
e.status !== EventStatus.ARCHIVED &&
e.status !== EventStatus.PENDING_APPROVAL);
// Advanced filters
const matchesDate =
!advancedFilters.date || e.date === advancedFilters.date;
const matchesFot =
!advancedFilters.fotId ||
String(e.fot || "").toLowerCase().includes(advancedFilters.fotId.toLowerCase());
const matchesType =
!advancedFilters.type || e.type === advancedFilters.type;
const matchesCompany =
!advancedFilters.company || e.empresa === advancedFilters.company;
const matchesInstitution =
!advancedFilters.institution || e.instituicao === advancedFilters.institution;
// New FOT Status Filter
const matchesFotStatus =
!advancedFilters.fotStatus ||
(advancedFilters.fotStatus === 'finalizada' && e.fot_finalizada) ||
(advancedFilters.fotStatus === 'pre_venda' && (e.fot_pre_venda || e.pre_venda)) ||
(advancedFilters.fotStatus === 'normal' && !e.fot_finalizada && !e.fot_pre_venda && !e.pre_venda);
return (
matchesSearch && matchesStatus && matchesDate && matchesFot && matchesType && matchesCompany && matchesInstitution && matchesFotStatus
);
});
// Keep selectedEvent in sync with global events state
useEffect(() => {
if (selectedEvent) {
const updated = events.find((e) => e.id === selectedEvent.id);
if (updated && updated !== selectedEvent) {
setSelectedEvent(updated);
}
}
}, [events, selectedEvent]);
const handleApprove = (e: React.MouseEvent, eventId: string) => {
e.stopPropagation();
updateEventStatus(eventId, EventStatus.CONFIRMED);
};
const handleReject = (e: React.MouseEvent, eventId: string, reason?: string) => {
e.stopPropagation();
updateEventStatus(eventId, EventStatus.ARCHIVED, reason);
};
const handleAssignmentResponse = async (
e: React.MouseEvent,
eventId: string,
status: string,
reason?: string
) => {
e.stopPropagation();
// Validação de Lotação da Equipe (Apenas para Aceite)
if (status === "ACEITO") {
const targetEvent = events.find(evt => evt.id === eventId);
const currentProfessional = professionals.find(p => p.usuarioId === user.id);
if (targetEvent && currentProfessional) {
// Reutilizando lógica de contagem (adaptada de calculateTeamStatus)
// Precisamos recalcular pois calculateTeamStatus depende de selectedEvent que pode não ser o alvo aqui
const assignments = targetEvent.assignments || [];
const hasRole = (professional: Professional | undefined, roleSlug: string) => {
if (!professional) return false;
const term = roleSlug.toLowerCase();
if (professional.functions && professional.functions.length > 0) {
return professional.functions.some(f => f.nome.toLowerCase().includes(term));
}
return (professional.role || "").toLowerCase().includes(term);
};
// Helper to check if assignment handles a specific role (using ID first, then fallback)
const isAssignedToRole = (assignment: any, roleSlug: string) => {
if (assignment.funcaoId && functions) {
const func = functions.find(f => f.id === assignment.funcaoId);
if (func) return func.nome.toLowerCase().includes(roleSlug.toLowerCase());
}
// Fallback only if no function assigned
if (!assignment.funcaoId) {
const p = professionals.find(pr => pr.id === assignment.professionalId);
return hasRole(p, roleSlug);
}
return false;
};
// Contagens Atuais (Updated to match EventTable logic)
const acceptedFot = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "fot")).length;
const acceptedRecep = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "recep")).length;
const acceptedCine = assignments.filter(a => a.status === "ACEITO" && isAssignedToRole(a, "cine")).length;
// Limites
const reqFot = targetEvent.qtdFotografos || 0;
const reqRecep = targetEvent.qtdRecepcionistas || 0;
const reqCine = targetEvent.qtdCinegrafistas || 0;
// Find OUR assignment to know what we are accepting
const myAssignment = assignments.find(a => a.professionalId === currentProfessional.id);
const checkQuota = (roleSlugs: string[], acceptedCount: number, requiredCount: number, roleName: string) => {
// Only check quota if I am assigned to this role
if (myAssignment) {
if (isAssignedToRole(myAssignment, roleSlugs[0])) { // Using first slug as proxy for role group
if (acceptedCount >= requiredCount) {
return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`;
}
}
} else {
// Fallback if no assignment found (shouldn't happen for existing invite)
const isProfessionalRole = roleSlugs.some(slug => hasRole(currentProfessional, slug));
if (isProfessionalRole && acceptedCount >= requiredCount) {
return `A equipe de ${roleName} já está completa (${acceptedCount}/${requiredCount}).`;
}
}
return null;
};
const errors = [];
const errFot = checkQuota(["fot"], acceptedFot, reqFot, "Fotografia");
if (errFot) errors.push(errFot);
const errRecep = checkQuota(["recep"], acceptedRecep, reqRecep, "Recepção");
if (errRecep) errors.push(errRecep);
const errCine = checkQuota(["cine"], acceptedCine, reqCine, "Cinegrafia");
if (errCine) errors.push(errCine);
if (errors.length > 0) {
alert(`Não foi possível aceitar o convite:\n\n${errors.join("\n")}\n\nEntre em contato com a administração.`);
return; // Bloqueia a ação
}
}
}
await respondToAssignment(eventId, status, reason);
};
const handleOpenMaps = () => {
if (!selectedEvent) return;
if (selectedEvent.address.mapLink) {
window.open(selectedEvent.address.mapLink, "_blank");
return;
}
const { street, number, city, state } = selectedEvent.address;
const query = encodeURIComponent(
`${street}, ${number}, ${city} - ${state}`
);
window.open(
`https://www.google.com/maps/search/?api=1&query=${query}`,
"_blank"
);
};
const handleApproveEvent = async (e: React.MouseEvent) => {
e.stopPropagation();
if (selectedEvent) {
if(window.confirm("Tem certeza que deseja aprovar este evento?")) {
await updateEventStatus(selectedEvent.id, EventStatus.CONFIRMED);
// Optimistically update
setSelectedEvent({ ...selectedEvent, status: EventStatus.CONFIRMED });
}
}
};
const handleFinalizeClass = async (e: React.MouseEvent, eventToFinalize?: EventData) => {
e.stopPropagation();
const targetEvent = eventToFinalize || selectedEvent;
if (!targetEvent || !targetEvent.fotId) return;
// Check permissions (Optional, backend also checks)
if (user.role !== UserRole.SUPERADMIN && user.role !== UserRole.BUSINESS_OWNER) {
alert("Você não tem permissão para finalizar turmas.");
return;
}
const isFinalized = targetEvent.fot_finalizada;
const action = isFinalized ? "reabrir" : "finalizar";
if(window.confirm(`Tem certeza que deseja ${action} a turma ${targetEvent.fot}?`)) {
try {
if (!token) {
alert("Erro de autenticação. Tente fazer login novamente.");
return;
}
await finalizeFOT(targetEvent.fotId, !isFinalized, token);
alert(`Turma ${action === "reabrir" ? "reaberta" : "finalizada"} com sucesso!`);
// Refresh events list
if (refreshEvents) {
refreshEvents();
}
// Update selected event if it matches
if (selectedEvent && selectedEvent.fotId === targetEvent.fotId) {
setSelectedEvent({ ...selectedEvent, fot_finalizada: !isFinalized });
}
} catch (error: any) {
alert("Erro ao alterar status da turma: " + error.message);
}
}
};
const handleManageTeam = () => {
setIsTeamModalOpen(true);
};
const togglePhotographer = async (photographerId: string) => {
if (!selectedEvent) return;
if (processingIds.has(photographerId)) return; // Prevent double click
const prof = professionals.find(p => p.id === photographerId);
const assignment = selectedEvent.assignments?.find(a => a.professionalId === photographerId);
if (!assignment && prof && prof.functions && prof.functions.length > 1) {
setRoleSelectionProf(prof);
return;
}
setProcessingIds(prev => new Set(prev).add(photographerId));
try {
await assignPhotographer(selectedEvent.id, photographerId);
} finally {
setProcessingIds(prev => {
const next = new Set(prev);
next.delete(photographerId);
return next;
});
}
};
const handleRoleSelect = async (funcaoId: string) => {
if (roleSelectionProf && selectedEvent) {
setProcessingIds(prev => new Set(prev).add(roleSelectionProf.id));
try {
await assignPhotographer(selectedEvent.id, roleSelectionProf.id, funcaoId);
} finally {
setProcessingIds(prev => {
const next = new Set(prev);
next.delete(roleSelectionProf.id);
return next;
});
setRoleSelectionProf(null);
}
}
};
// --- RENDERS PER ROLE ---
const renderRoleSpecificHeader = () => {
if (user.role === UserRole.EVENT_OWNER) {
return (
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
Meus Eventos
</h1>
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
Acompanhe seus eventos ou solicite novos orçamentos.
</p>
</div>
);
}
if (user.role === UserRole.PHOTOGRAPHER) {
return (
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
Eventos Designados
</h1>
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
Gerencie seus trabalhos e visualize detalhes.
</p>
</div>
);
}
return (
<div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-serif font-bold text-brand-black">
Gestão Geral
</h1>
<p className="text-xs sm:text-sm text-gray-500 mt-0.5 sm:mt-1">
Controle total de eventos, aprovações e equipes.
</p>
</div>
);
};
const renderRoleSpecificActions = () => {
if (user.role === UserRole.PHOTOGRAPHER || user.role === UserRole.AGENDA_VIEWER || user.role === UserRole.RESEARCHER) return null;
const label =
user.role === UserRole.EVENT_OWNER
? "Solicitar Novo Evento"
: "Novo Evento";
return (
<Button onClick={() => setView("create")} className="shadow-lg">
<PlusCircle className="mr-2 h-5 w-5" /> {label}
</Button>
);
};
// --- MAIN RENDER ---
return (
<div className="min-h-screen bg-gray-50 pt-20 sm:pt-24 md:pt-28 lg:pt-32 pb-8 sm:pb-12 px-3 sm:px-4 lg:px-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
<div className="flex flex-col md:flex-row md:items-center justify-between mb-6 sm:mb-8 gap-3 sm:gap-4 fade-in">
{renderRoleSpecificHeader()}
{renderRoleSpecificActions()}
</div>
)}
{/* Content Switcher */}
{view === "list" && !new URLSearchParams(location.search).get("eventId") && (
<div className="space-y-6 fade-in">
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'PENDENTE') && (
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-purple/20 mb-6">
<div className="bg-gradient-to-r from-brand-purple/10 to-transparent p-4 border-b border-brand-purple/10">
<h3 className="font-bold text-brand-purple flex items-center gap-2">
<Calendar size={18} /> Convites Pendentes
</h3>
<p className="text-xs text-gray-600 mt-1">Você foi selecionado para atuar nestas datas. Confirme sua disponibilidade.</p>
</div>
<div className="divide-y divide-gray-100">
{dailyInvitations.filter(c => c.status === 'PENDENTE').map(inv => (
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-purple/10 text-brand-purple flex items-center justify-center font-bold">
{inv.data ? inv.data.split('-')[2] : '--'}
</div>
<div>
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
<p className="text-xs text-brand-gold">Plantão Diário</p>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<Button
variant="outline"
onClick={() => handleRespondDailyInvitation(inv.id, 'REJEITADO')}
className="flex-1 sm:flex-none text-red-600 border-red-200 hover:bg-red-50 hover:border-red-300"
>
<X size={16} className="mr-1" /> Recusar
</Button>
<Button
onClick={() => handleRespondDailyInvitation(inv.id, 'ACEITO')}
className="flex-1 sm:flex-none bg-brand-purple hover:bg-brand-purple-dark text-white"
>
<CheckCircle size={16} className="mr-1" /> Confirmar
</Button>
</div>
</div>
))}
</div>
</div>
)}
{user.role === UserRole.PHOTOGRAPHER && dailyInvitations.some(c => c.status === 'ACEITO') && (
<div className="bg-white border rounded-lg overflow-hidden shadow-sm border-brand-gold/20 mb-6">
<div className="bg-gradient-to-r from-brand-gold/10 to-transparent p-4 border-b border-brand-gold/10">
<h3 className="font-bold text-brand-gold flex items-center gap-2">
<CheckCircle size={18} /> Datas Confirmadas
</h3>
<p className="text-xs text-gray-600 mt-1">Sua presença está confirmada nestas datas. Aguarde a alocação num evento específico e o envio do briefing.</p>
</div>
<div className="divide-y divide-gray-100">
{dailyInvitations.filter(c => c.status === 'ACEITO').map(inv => (
<div key={inv.id} className="p-4 flex flex-col sm:flex-row items-center justify-between gap-4 hover:bg-gray-50/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-brand-gold/10 text-brand-gold flex items-center justify-center font-bold">
{inv.data ? inv.data.split('-')[2] : '--'}
</div>
<div>
<p className="font-bold text-gray-900">{inv.data ? inv.data.split('-').reverse().join('/') : ''}</p>
<p className="text-xs text-brand-purple mt-0.5 font-medium"><Clock size={12} className="inline mr-1 mb-0.5"/> Aguardando Definição de Local Turma</p>
</div>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<span className="px-3 py-1 flex items-center text-sm font-bold bg-green-100 text-green-700 rounded-lg whitespace-nowrap">
<CheckCircle size={16} className="mr-2" /> Agendado
</span>
</div>
</div>
))}
</div>
</div>
)}
{/* Search Bar */}
<div className="flex flex-col sm:flex-row gap-4 items-center justify-between bg-gray-50 p-3 rounded-lg border border-gray-100">
<div className="relative flex-1 w-full">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-4 w-4" />
<input
type="text"
placeholder="Buscar evento..."
className="w-full pl-10 pr-4 py-2 bg-white border border-gray-200 rounded-sm focus:outline-none focus:border-brand-gold text-sm"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<div className="flex space-x-2 bg-white p-1 rounded border border-gray-200">
<button
onClick={() => setActiveFilter("all")}
className={`px-3 py-1 text-xs font-medium rounded-sm ${activeFilter === "all"
? "bg-brand-black text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
Todos
</button>
<button
onClick={() => setActiveFilter("pending")}
className={`px-3 py-1 text-xs font-medium rounded-sm flex items-center ${activeFilter === "pending"
? "bg-brand-gold text-white"
: "text-gray-600 hover:bg-gray-100"
}`}
>
<Clock size={12} className="mr-1" /> Pendentes
</button>
</div>
)}
</div>
{/* Advanced Filters */}
<EventFiltersBar
filters={advancedFilters}
onFilterChange={setAdvancedFilters}
availableTypes={availableTypes}
availableCompanies={availableCompanies}
availableInstitutions={availableInstitutions}
/>
{/* Results Count */}
<div className="flex items-center justify-between text-sm text-gray-600">
<span>
Exibindo{" "}
<strong className="text-brand-gold">
{filteredEvents.length}
</strong>{" "}
de <strong>{myEvents.length}</strong> eventos
</span>
</div>
{/* Event Table */}
<EventTable
events={filteredEvents}
onEventClick={(event) => {
navigate(`?eventId=${event.id}`);
}}
onApprove={handleApprove}
onReject={handleReject}
userRole={user.role}
currentProfessionalId={currentProfessionalId}
onAssignmentResponse={handleAssignmentResponse}
isManagingTeam={true} // Permitir aprovação na gestão geral
professionals={professionals} // Adicionar lista de profissionais
functions={functions}
isLoading={isLoading}
onFinalizeClass={handleFinalizeClass}
/>
</div>
)}
{(view === "create" || view === "edit") && (
<EventForm
onCancel={() => setView(view === "edit" ? "details" : "list")}
onSubmit={handleSaveEvent}
initialData={view === "edit" ? selectedEvent : undefined}
/>
)}
{/* Loading State for Deep Link */ }
{(!!new URLSearchParams(location.search).get("eventId") && !selectedEvent) && (
<div className="flex flex-col items-center justify-center py-20 fade-in">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-purple mb-4"></div>
<p className="text-gray-500">Carregando detalhes do evento...</p>
</div>
)}
{view === "details" && selectedEvent && (
<div className="fade-in">
<Button
variant="ghost"
onClick={() => {
const newParams = new URLSearchParams(location.search);
newParams.delete("eventId");
navigate({ search: newParams.toString() });
setView("list");
}}
className="mb-4 pl-0"
>
Voltar para lista
</Button>
{/* Status Banner */}
{selectedEvent.status === EventStatus.PENDING_APPROVAL &&
user.role === UserRole.EVENT_OWNER && (
<div className="bg-yellow-50 border border-yellow-200 text-yellow-800 p-4 rounded-lg mb-6 flex items-start">
<Clock className="mr-3 flex-shrink-0" />
<div>
<h4 className="font-bold">Solicitação em Análise</h4>
<p className="text-sm mt-1">
Seu evento foi enviado e está aguardando aprovação da
equipe Photum.
</p>
</div>
</div>
)}
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
{/* Header Section - Sem foto */}
<div className="bg-gradient-to-r from-brand-gold/5 to-brand-black/5 border-b-2 border-brand-gold p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-serif font-bold text-brand-black mb-2">
{selectedEvent.name}
</h1>
<div className="flex flex-wrap gap-3 text-sm text-gray-600">
<span className="flex items-center gap-1">
<Calendar size={16} className="text-brand-gold" />
{new Date(
selectedEvent.date + "T00:00:00"
).toLocaleDateString("pt-BR")}{" "}
às {selectedEvent.time}
</span>
<span className="flex items-center gap-1">
<MapPin size={16} className="text-brand-gold" />
{selectedEvent.address.city},{" "}
{selectedEvent.address.state}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{/* Badge de Finalizada */}
{(selectedEvent.fot_finalizada) && (
<div className="px-4 py-2 rounded text-sm font-semibold bg-red-100 text-red-800 border border-red-200">
Turma Finalizada
</div>
)}
{/* Badge de Pré-Venda */}
{((selectedEvent.fot_pre_venda || selectedEvent.pre_venda) && !selectedEvent.fot_finalizada) && (
<div className="px-4 py-2 rounded text-sm font-semibold bg-blue-100 text-blue-800 border border-blue-200">
Pré-Venda
</div>
)}
<div
className={`px-4 py-2 rounded text-sm font-semibold ${STATUS_COLORS[selectedEvent.status]
}`}
>
{selectedEvent.status}
</div>
</div>
</div>
</div>
<div className="p-6">
<div className="flex flex-wrap gap-2 mb-6 pb-4 border-b">
<Button
variant="primary"
onClick={() => navigate(`/agenda/${selectedEvent.id}`)}
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
>
<CheckCircle size={16} className="mr-2" /> Área Operacional
</Button>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<>
<Button
variant="outline"
onClick={() => setView("edit")}
className="text-sm"
>
<Edit size={16} className="mr-2" /> Editar Detalhes
</Button>
<Button
variant="outline"
onClick={handleManageTeam}
className="text-sm"
>
<Users size={16} className="mr-2" /> Gerenciar Equipe
</Button>
</>
)}
{user.role === UserRole.EVENT_OWNER &&
selectedEvent.status !== EventStatus.ARCHIVED && (
<Button
variant="outline"
onClick={() => setView("edit")}
className="text-sm"
>
<Edit size={16} className="mr-2" /> Editar Informações
</Button>
)}
<Button
variant="outline"
onClick={handleOpenMaps}
className="text-sm"
>
<Map size={16} className="mr-2" /> Abrir no Maps
</Button>
{/* Botão de Aprovação (Apenas Admin/Owner se pendente) */}
{(user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) &&
selectedEvent.status === EventStatus.PENDING_APPROVAL && (
<Button
variant="primary"
onClick={handleApproveEvent}
className="text-sm bg-green-600 text-white hover:bg-green-700 border-green-600"
>
<CheckCircle size={16} className="mr-2" /> Aprovar
</Button>
)}
<Button
variant="default"
onClick={() => {
// Use a direct window location or navigate if available.
// Since Dashboard doesn't seem to expose navigate cleanly, likely we need to pass it or use window.location
// Actually, Dashboard is usually wrapped in a Router context.
// We can use window.open or window.location.href for now as Dashboard props don't include navigation function easily accessible without hooking.
// However, line 348 uses setView("list"). Ideally we use useNavigate.
// Looking at lines 34-45, we don't have navigate.
// But Dashboard is inside PageWrapper which has navigate? No.
// Let's use window.location.assign for simplicity or add useNavigate.
window.location.assign(`/agenda/${selectedEvent.id}`);
}}
className="text-sm bg-brand-purple text-white hover:bg-brand-purple/90"
>
<CheckCircle size={16} className="mr-2" /> Área Operacional
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
{/* Quick Info Cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-50 p-4 rounded border border-gray-200">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
Tipo
</p>
<p className="font-semibold text-gray-900">
{selectedEvent.type}
</p>
</div>
<div className="bg-gray-50 p-4 rounded border border-gray-200">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
Data
</p>
<p className="font-semibold text-gray-900">
{new Date(
selectedEvent.date + "T00:00:00"
).toLocaleDateString("pt-BR")}
</p>
</div>
<div className="bg-gray-50 p-4 rounded border border-gray-200">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">
Horário
</p>
<p className="font-semibold text-gray-900">
{selectedEvent.time}
</p>
</div>
{basePrice !== null && (
<div className={`p-4 rounded border ${basePrice > 0 ? "bg-green-50 border-green-200" : "bg-gray-50 border-gray-200"}`}>
<p className={`text-xs uppercase tracking-wide mb-1 font-bold ${basePrice > 0 ? "text-green-700" : "text-gray-500"}`}>
Valor Base
</p>
<p className={`font-bold ${basePrice > 0 ? "text-green-800" : "text-gray-700"}`}>
{basePrice > 0 ? basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" }) : "R$ 0,00"}
</p>
</div>
)}
</div>
{/* FOT Information Table */}
<section className="bg-white border border-gray-200 rounded-lg overflow-hidden">
<div className="bg-gradient-to-r from-brand-purple to-brand-purple/90 px-4 py-3">
<h3 className="text-base font-bold text-white">
Informações FOT
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<tbody className="divide-y divide-gray-200">
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50 w-1/3">
FOT
</td>
<td className="px-4 py-3 text-sm text-gray-900 font-medium">
{(selectedEvent as any).fot || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Data
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{new Date(
selectedEvent.date + "T00:00:00"
).toLocaleDateString("pt-BR")}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Curso
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.curso || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Instituição
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.instituicao || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Ano Formatura
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.anoFormatura || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Empresa
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.empresa || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Tipo Evento
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.type}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Observações
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).observacoes || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Local
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{(selectedEvent as any).local_evento || (selectedEvent as any).locationName || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Endereço
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.address.street}, {selectedEvent.address.number} - {selectedEvent.address.city}/{selectedEvent.address.state}
{selectedEvent.address.zip && ` | CEP: ${selectedEvent.address.zip}`}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
QTD Formandos
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdFormandos || (selectedEvent as any).qtd_formandos || selectedEvent.attendees || "-"}
</td>
</tr>
{/* 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">
Gestão de Equipe e Recursos
</td>
</tr>
{/* Helper to calculate pending counts */
(() => {
const teamStatus = calculateTeamStatus(selectedEvent);
const renderResourceRow = (label: string, confirmed: number, pending: number, required: number) => {
if (!required && confirmed === 0 && pending === 0) {
return (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
{label}
</td>
<td className="px-4 py-3 text-sm text-gray-500">-</td>
</tr>
);
}
const isComplete = confirmed >= required;
return (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
{label}
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className={`font-bold ${isComplete ? 'text-green-600' : 'text-gray-900'}`}>{confirmed}</span>
<span className="text-gray-400">/</span>
<span className="font-medium">{required || 0}</span>
{isComplete && <CheckCircle size={14} className="text-green-500 ml-1" />}
</div>
{pending > 0 && (
<span className="text-[10px] text-yellow-600 font-medium">
({pending} aguardando aceite)
</span>
)}
</div>
</td>
</tr>
);
};
return (
<>
{renderResourceRow(
"Fotógrafo",
teamStatus.acceptedFotografos,
teamStatus.pendingFotografos,
selectedEvent.qtdFotografos || 0
)}
{renderResourceRow(
"Recepcionista",
teamStatus.acceptedRecepcionistas,
teamStatus.pendingRecepcionistas,
selectedEvent.qtdRecepcionistas || 0
)}
{renderResourceRow(
"Cinegrafista",
teamStatus.acceptedCinegrafistas,
teamStatus.pendingCinegrafistas,
selectedEvent.qtdCinegrafistas || 0
)}
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Estúdio
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdEstudios || (selectedEvent as any).qtd_estudios || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Ponto de Foto
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdPontosFoto || (selectedEvent as any).qtd_ponto_foto || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Ponto Decorado
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdPontosDecorados || (selectedEvent as any).qtd_ponto_decorado || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Ponto LED
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdPontosLed || (selectedEvent as any).qtd_pontos_led || "-"}
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Plataforma 360
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.qtdPlataforma360 || (selectedEvent as any).qtd_plataforma_360 || "-"}
</td>
</tr>
</>
);
})()}
{/* Status e Faltantes */}
{(() => {
const teamStatus = calculateTeamStatus(selectedEvent);
return (
<>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Profissionais OK?
</td>
<td className="px-4 py-3 text-sm">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium ${
teamStatus.profissionaisOK
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}>
{teamStatus.profissionaisOK ? (
<>
<CheckCircle size={12} />
Completo
</>
) : (
<>
<X size={12} />
Incompleto
</>
)}
</span>
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Foto Faltante
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<span className={`font-medium ${teamStatus.fotoFaltante > 0 ? "text-red-600" : "text-green-600"}`}>
{teamStatus.fotoFaltante}
</span>
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Recep. Faltante
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<span className={`font-medium ${teamStatus.recepFaltante > 0 ? "text-red-600" : "text-green-600"}`}>
{teamStatus.recepFaltante}
</span>
</td>
</tr>
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Cine Faltante
</td>
<td className="px-4 py-3 text-sm text-gray-900">
<span className={`font-medium ${teamStatus.cineFaltante > 0 ? "text-red-600" : "text-green-600"}`}>
{teamStatus.cineFaltante}
</span>
</td>
</tr>
</>
);
})()}
</>
)}
{/* Base Value Card for Professionals */}
{basePrice !== null && basePrice > 0 && (
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Valor Base
</td>
<td className="px-4 py-3 text-sm text-gray-900 font-bold text-green-700">
{basePrice.toLocaleString("pt-BR", { style: "currency", currency: "BRL" })}
</td>
</tr>
)}
<tr className="hover:bg-gray-50">
<td className="px-4 py-3 text-xs font-semibold text-gray-600 uppercase tracking-wider bg-gray-50">
Horário
</td>
<td className="px-4 py-3 text-sm text-gray-900">
{selectedEvent.time}
</td>
</tr>
</tbody>
</table>
</div>
</section>
{/* Institution Information */}
{selectedEvent.institutionId &&
(() => {
const institution = getInstitutionById(
selectedEvent.institutionId
);
if (institution) {
return (
<section className="bg-gradient-to-br from-brand-gold/10 to-transparent border border-brand-gold/30 rounded-sm p-6">
<div className="flex items-start space-x-4">
<div className="bg-brand-gold/20 p-3 rounded-full">
<Building2
className="text-brand-gold"
size={24}
/>
</div>
<div className="flex-1">
<h3 className="text-lg font-bold text-brand-black mb-1">
{institution.name}
</h3>
<p className="text-sm text-brand-gold uppercase tracking-wide font-medium mb-3">
{institution.type}
</p>
{/* Course Information */}
{selectedEvent.courseId &&
(() => {
const course =
getActiveCoursesByInstitutionId(
selectedEvent.institutionId
).find(
(c) => c.id === selectedEvent.courseId
);
if (course) {
return (
<div className="bg-brand-gold/10 border border-brand-gold/30 rounded px-3 py-2 mb-3">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-0.5">
Curso/Turma
</p>
<p className="text-sm font-semibold text-brand-black">
{course.name} -{" "}
{course.graduationType} (
{course.year}/{course.semester}º
sem)
</p>
</div>
);
}
return null;
})()}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<p className="text-gray-500 text-xs uppercase tracking-wide">
Contato
</p>
<p className="text-gray-700 font-medium">
{institution.phone}
</p>
<p className="text-gray-600">
{institution.email}
</p>
</div>
{institution.address && (
<div>
<p className="text-gray-500 text-xs uppercase tracking-wide">
Endereço
</p>
<p className="text-gray-700">
{institution.address.street},{" "}
{institution.address.number}
</p>
<p className="text-gray-600">
{institution.address.city} -{" "}
{institution.address.state}
</p>
</div>
)}
</div>
{institution.description && (
<p className="text-gray-600 text-sm mt-3 italic border-t border-brand-gold/20 pt-3">
{institution.description}
</p>
)}
</div>
</div>
</section>
);
}
return null;
})()}
<section>
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
Sobre o Evento
</h3>
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">
{selectedEvent.briefing || "Sem briefing detalhado."}
</p>
</section>
{selectedEvent.contacts.length > 0 && (
<section>
<h3 className="text-lg font-bold border-b pb-2 mb-4 text-brand-black">
Contatos & Responsáveis
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{selectedEvent.contacts.map((c, i) => (
<div
key={i}
className="bg-gray-50 p-4 rounded-sm border border-gray-100"
>
<p className="font-bold text-sm">{c.name}</p>
<p className="text-xs text-brand-gold uppercase tracking-wide">
{c.role}
</p>
<p className="text-sm text-gray-500 mt-1">
{c.phone}
</p>
</div>
))}
</div>
</section>
)}
</div>
<div className="lg:col-span-1 space-y-4">
{/* Localização Card */}
<div className="border p-5 rounded bg-gray-50">
<h4 className="font-bold text-sm mb-3 text-gray-700 flex items-center gap-2">
<MapPin size={16} className="text-brand-gold" />
Localização
</h4>
<p className="font-medium text-base mb-1">
{selectedEvent.address.street},{" "}
{selectedEvent.address.number}
</p>
<p className="text-gray-600 text-sm">
{selectedEvent.address.city} -{" "}
{selectedEvent.address.state}
</p>
{selectedEvent.address.zip && (
<p className="text-gray-500 text-xs mt-1">
CEP: {selectedEvent.address.zip}
</p>
)}
</div>
{/* Equipe Designada */}
{(selectedEvent.assignments && selectedEvent.assignments.filter(a => a.status !== "REJEITADO").length > 0) ||
((user.role === UserRole.BUSINESS_OWNER || user.role === UserRole.SUPERADMIN) && selectedEvent.photographerIds.length > 0) ? (
<div className="border p-5 rounded bg-white">
<div className="flex justify-between items-center mb-3">
<h4 className="font-bold text-sm text-gray-700 flex items-center gap-2">
<Users size={16} className="text-brand-gold" />
Equipe ({(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length})
</h4>
{(user.role === UserRole.BUSINESS_OWNER ||
user.role === UserRole.SUPERADMIN) && (
<button
onClick={handleManageTeam}
className="text-brand-gold hover:text-brand-black transition-colors"
title="Adicionar fotógrafo"
>
<PlusCircle size={18} />
</button>
)}
</div>
{(selectedEvent.assignments || [])
.filter(a => a.status !== "REJEITADO")
.map((assignment) => {
const photographer = professionals.find(
(p) => p.id === assignment.professionalId
);
return (
<div
key={assignment.professionalId}
className="flex items-center justify-between gap-2 text-sm mb-2"
>
<div
className="flex items-center gap-2 cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleViewProfessional(photographer!)}
>
<div
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",
}}
>
{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"}
{assignment.funcaoId && functions && (
<span className="block text-xs font-semibold text-blue-600">
{functions.find(f => f.id === assignment.funcaoId)?.nome || ""}
</span>
)}
</span>
</div>
</div>
{assignment.status === "PENDENTE" && (
<span className="w-2 h-2 rounded-full bg-yellow-400" title="Pendente"></span>
)}
{assignment.status === "ACEITO" && (
<span className="w-2 h-2 rounded-full bg-green-500" title="Aceito"></span>
)}
</div>
);
})}
{(selectedEvent.assignments || []).filter(a => a.status !== "REJEITADO").length === 0 && (
<p className="text-sm text-gray-400 italic">
Nenhum profissional na equipe.
</p>
)}
</div>
) : null
}
</div>
</div>
</div>
</div>
</div>
)}
{/* Modal de Gerenciamento de Equipe */}
{isTeamModalOpen && selectedEvent && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
{/* Header do Modal */}
<div className="flex items-center justify-between p-6 border-b border-gray-100">
<div>
<h2 className="text-xl font-serif font-bold text-brand-black">
Gerenciar Equipe
</h2>
<p className="text-sm text-gray-500">
{selectedEvent.name} - {new Date(selectedEvent.date + 'T00:00:00').toLocaleDateString('pt-BR')}
</p>
</div>
<button
onClick={closeTeamModal}
className="p-2 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* Resource Summary Bar */}
<div className="bg-blue-50 px-6 py-3 border-b border-blue-100 flex flex-wrap gap-4 md:gap-8 overflow-x-auto">
{(() => {
const stats = calculateTeamStatus(selectedEvent);
return (
<>
<div className="flex items-center gap-2 min-w-fit">
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Fotógrafos:</span>
<div className="flex items-baseline gap-1">
<span className={`text-sm font-bold ${stats.fotoFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.acceptedFotografos}
</span>
<span className="text-xs text-blue-600">/ {stats.qtdFotografos}</span>
{stats.fotoFaltante === 0 ? (
<CheckCircle size={14} className="text-green-500 ml-1" />
) : (
<AlertCircle size={14} className="text-red-500 ml-1" />
)}
</div>
</div>
<div className="flex items-center gap-2 min-w-fit">
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Cinegrafistas:</span>
<div className="flex items-baseline gap-1">
<span className={`text-sm font-bold ${stats.cineFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.acceptedCinegrafistas}
</span>
<span className="text-xs text-blue-600">/ {stats.qtdCinegrafistas}</span>
{stats.cineFaltante === 0 ? (
<CheckCircle size={14} className="text-green-500 ml-1" />
) : (
<AlertCircle size={14} className="text-red-500 ml-1" />
)}
</div>
</div>
<div className="flex items-center gap-2 min-w-fit">
<span className="text-xs font-bold text-blue-800 uppercase tracking-wider">Recepcionistas:</span>
<div className="flex items-baseline gap-1">
<span className={`text-sm font-bold ${stats.recepFaltante > 0 ? 'text-red-600' : 'text-green-600'}`}>
{stats.acceptedRecepcionistas}
</span>
<span className="text-xs text-blue-600">/ {stats.qtdRecepcionistas}</span>
{stats.recepFaltante === 0 ? (
<CheckCircle size={14} className="text-green-500 ml-1" />
) : (
<AlertCircle size={14} className="text-red-500 ml-1" />
)}
</div>
</div>
</>
);
})()}
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-6">
<div className="mb-4">
<p className="text-sm text-gray-600 mb-2">
Profissionais disponíveis para a data{" "}
<strong>
{new Date(
selectedEvent.date + "T00:00:00"
).toLocaleDateString("pt-BR")}
</strong>
. Clique em "Adicionar" para atribuir ao evento.
</p>
{/* Totalizador de Plantão */}
{eventDailyInvitations.length > 0 && (
<div className="flex items-center gap-2 bg-brand-gold/10 text-brand-gold-dark border border-brand-gold/20 p-3 rounded-lg">
<CheckCircle size={16} className="text-brand-gold" />
<span className="text-sm font-medium">
{eventDailyInvitations.filter(i => i.status === 'ACEITO').length} profissional(is) confirmou(aram) plantão para este dia na Logística Diária.
</span>
</div>
)}
</div>
{/* Filtros e Busca */}
<div className="mb-6 space-y-4">
{/* Barra de busca */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
placeholder="Buscar por nome ou email..."
value={teamSearchTerm}
onChange={(e) => setTeamSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple focus:border-transparent"
/>
</div>
{/* Filtros */}
<div className="flex flex-wrap gap-3">
{/* Filtro por função */}
<select
value={teamRoleFilter}
onChange={(e) => setTeamRoleFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple focus:border-transparent text-sm"
>
<option value="all">Todas as funções</option>
{functions?.map(fn => (
<option key={fn.id} value={fn.id}>{fn.nome}</option>
))}
</select>
{/* Filtro por status */}
<select
value={teamStatusFilter}
onChange={(e) => setTeamStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple focus:border-transparent text-sm"
>
<option value="all">Todos os status</option>
<option value="assigned">Designados</option>
<option value="available">Disponíveis</option>
<option value="busy">Em outro evento</option>
<option value="rejected">Rejeitados</option>
</select>
{/* Filtro por disponibilidade */}
<select
value={teamAvailabilityFilter}
onChange={(e) => setTeamAvailabilityFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple focus:border-transparent text-sm"
>
<option value="all">Todas disponibilidades</option>
<option value="available">Disponíveis</option>
<option value="unavailable">Indisponíveis</option>
</select>
{/* Toggle Confirmados no Plantão */}
<label className={`flex items-center gap-2 px-3 py-2 border rounded-lg cursor-pointer transition-colors text-sm ${showOnlyDailyAccepted ? 'bg-brand-gold/10 text-brand-gold border-brand-gold/30' : 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50'}`}>
<input
type="checkbox"
className="rounded text-brand-gold focus:ring-brand-gold bg-white border-gray-300"
checked={showOnlyDailyAccepted}
onChange={(e) => setShowOnlyDailyAccepted(e.target.checked)}
/>
<span className="font-semibold">Confirmados no Plantão</span>
</label>
{/* Botão limpar filtros */}
{(teamSearchTerm || teamRoleFilter !== "all" || teamStatusFilter !== "all" || teamAvailabilityFilter !== "all" || showOnlyDailyAccepted) && (
<button
onClick={() => {
setTeamSearchTerm("");
setTeamRoleFilter("all");
setTeamStatusFilter("all");
setTeamAvailabilityFilter("all");
setShowOnlyDailyAccepted(false);
}}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-1"
>
<X size={16} />
Limpar filtros
</button>
)}
</div>
</div>
{/* Tabela de Profissionais (Desktop) */}
<div className="hidden md:block overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-gray-50 border-b-2 border-gray-200">
<th className="text-left p-4 font-semibold text-gray-700">
Profissional
</th>
<th className="text-center p-4 font-semibold text-gray-700">
Função
</th>
<th className="text-center p-4 font-semibold text-gray-700">
E-mail
</th>
<th className="text-center p-4 font-semibold text-gray-700">
Status
</th>
<th className="text-center p-4 font-semibold text-gray-700">
Ação
</th>
</tr>
</thead>
<tbody>
{displayedTeamProfessionals.map((photographer) => {
const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === photographer.id
);
const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO";
// Optimization: Use busySet (O(1))
// We need the busy EVENT details if we want to show WHERE they are busy.
// But for speed, just checking if busy is enough for filtering.
// To show tooltip, we can do a lookup ONLY if they are busy.
// Check if busy in other events on the same date (using logic or Set)
const isBusyInSet = busyProfessionalIds.has(photographer.id);
const isBusy = !isAssigned && isBusyInSet;
// Only search for the specific event if we need to show the tooltip and they ARE busy
// This avoids searching for everyone.
const busyEvent = isBusy ? events.find(e =>
e.id !== selectedEvent.id &&
e.date === selectedEvent.date &&
(e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO')
) : undefined;
const isProcessing = processingIds.has(photographer.id);
return (
<tr
key={photographer.id}
className="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
{/* 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 border-gray-200 bg-gray-300 flex-shrink-0"
style={{
backgroundImage: `url(${photographer.avatar})`,
backgroundSize: "cover",
backgroundPosition: "center",
}}
/>
{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 className="flex-1">
<div className="flex items-center gap-2">
<p className="font-semibold text-gray-800 text-sm">
{photographer.name || photographer.nome}
</p>
{photographer.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-gray-500">
ID: {photographer.id.substring(0, 8)}...
</p>
{photographer.cidade && (
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
{photographer.cidade}
</span>
)}
</div>
</div>
</div>
</td>
{/* Função */}
<td className="p-4 text-center">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{photographer.functions && photographer.functions.length > 0
? photographer.functions.map(f => f.nome).join(", ")
: photographer.role}
</span>
</td>
{/* E-mail */}
<td className="p-4 text-center text-sm text-gray-600">
{photographer.email}
</td>
{/* Status */}
<td className="p-4 text-center">
{status === "ACEITO" && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium">
<CheckCircle size={14} />
Confirmado
</span>
)}
{status === "PENDENTE" && (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
<Clock size={14} />
Pendente
</span>
)}
{status === "REJEITADO" && (
<span
className="inline-flex items-center gap-1.5 px-3 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium cursor-help"
title={assignment?.reason ? `Motivo: ${assignment.reason}` : "Evento recusado pelo fotógrafo"}
>
<X size={14} />
Recusado
</span>
)}
{!status && (
<span
className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800 cursor-help" : "bg-gray-100 text-gray-600"}`}
title={busyEvent ? `Em evento: ${busyEvent.name} - ${busyEvent.local_evento || 'Local não informado'} (${busyEvent.time || busyEvent.startTime || 'Horário indefinido'})` : ""}
>
{isBusy ? <UserX size={14} /> : <UserCheck size={14} />}
{isBusy ? "Em outro evento" : "Disponível"}
</span>
)}
</td>
{/* Ação */}
<td className="p-4 text-center">
<div className="flex items-center justify-center gap-2">
<button
onClick={() =>
togglePhotographer(photographer.id)
}
disabled={(!status && isBusy) || isProcessing}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors flex items-center justify-center gap-2 ${status === "ACEITO" || status === "PENDENTE"
? "bg-red-100 text-red-700 hover:bg-red-200"
: "bg-brand-gold text-white hover:bg-[#a5bd2e]"
} ${isProcessing ? "opacity-70 cursor-wait" : ""}`}
>
{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>
);
})}
{/* Empty State */}
{filteredTeamProfessionals.length === 0 && (
<tr>
<td colSpan={5} className="p-8 text-center">
{/* ... existing empty state ... */}
<div className="flex flex-col items-center gap-3">
<UserX size={48} className="text-gray-300" />
<p className="text-gray-500 font-medium">
Ninguém encontrado.
</p>
</div>
</td>
</tr>
)}
{/* Load More Row */}
{displayedTeamProfessionals.length < filteredTeamProfessionals.length && (
<tr>
<td colSpan={5} className="p-4 text-center">
<button
onClick={() => setTeamPage(prev => prev + 1)}
className="text-brand-gold font-medium hover:underline flex items-center justify-center w-full"
>
Carregar mais ({filteredTeamProfessionals.length - displayedTeamProfessionals.length} restantes)
</button>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Lista de Cards (Mobile) */}
<div className="md:hidden space-y-4">
{displayedTeamProfessionals.map((photographer) => {
const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === photographer.id
);
const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO";
const isBusyInSet = busyProfessionalIds.has(photographer.id);
const isBusy = !isAssigned && isBusyInSet;
const isProcessing = processingIds.has(photographer.id);
return (
<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="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 className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-gray-800 text-sm truncate">{photographer.name || photographer.nome}</p>
{photographer.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<div className="flex items-center gap-2 mt-0.5">
<p className="text-xs text-gray-500 truncate">{assignment?.status === "PENDENTE" ? "Convite Pendente" : assignment?.status}</p>
{photographer.cidade && (
<span className={`inline-block px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(photographer.cidade)}`}>
{photographer.cidade}
</span>
)}
</div>
</div>
</div>
<div className="flex flex-wrap gap-2 mb-4">
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{photographer.functions && photographer.functions.length > 0
? photographer.functions.map(f => f.nome).join(", ")
: photographer.role}
</span>
{status === "ACEITO" && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-full text-xs font-medium">
<CheckCircle size={12} /> Confirmado
</span>
)}
{status === "PENDENTE" && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-yellow-100 text-yellow-800 rounded-full text-xs font-medium">
<Clock size={12} /> Pendente
</span>
)}
{status === "REJEITADO" && (
<span
className="inline-flex items-center gap-1 px-2 py-1 bg-red-100 text-red-800 rounded-full text-xs font-medium cursor-help"
title={assignment?.reason ? `Motivo: ${assignment.reason}` : "Evento recusado pelo fotógrafo"}
>
<X size={12} /> Recusado
</span>
)}
{!status && (
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${isBusy ? "bg-red-100 text-red-800" : "bg-gray-100 text-gray-600"}`}>
{isBusy ? <UserX size={12} /> : <UserCheck size={12} />}
{isBusy ? "Em outro evento" : "Disponível"}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleViewProfessional(photographer)}
className="flex-1 py-2 border border-gray-300 rounded-lg text-sm font-medium text-gray-700 hover:bg-gray-50 bg-white"
>
Ver Detalhes
</button>
<button
onClick={() => togglePhotographer(photographer.id)}
disabled={(!status && isBusy) || isProcessing}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors flex items-center justify-center gap-2 ${status === "ACEITO" || status === "PENDENTE"
? "bg-red-100 text-red-700 hover:bg-red-200"
: isBusy ? "bg-gray-300 text-gray-500 cursor-not-allowed" : "bg-brand-gold text-white hover:bg-[#a5bd2e]"
} ${isProcessing ? "opacity-70 cursor-wait" : ""}`}
>
{isProcessing && <div className="w-3 h-3 border-2 border-current border-t-transparent rounded-full animate-spin"></div>}
{status === "ACEITO" || status === "PENDENTE" ? "Remover" : isBusy ? "Ocupado" : "Adicionar"}
</button>
</div>
</div>
);
})}
{/* Load More Mobile */}
{displayedTeamProfessionals.length < filteredTeamProfessionals.length && (
<div className="p-4 text-center">
<button
onClick={() => setTeamPage(prev => prev + 1)}
className="text-brand-gold font-medium hover:underline flex items-center justify-center w-full bg-white p-3 rounded shadow-sm border border-gray-100"
>
Carregar mais ({filteredTeamProfessionals.length - displayedTeamProfessionals.length} restantes)
</button>
</div>
)}
{filteredTeamProfessionals.length === 0 && (
<div className="text-center p-8 text-gray-500">
<UserX size={48} className="mx-auto text-gray-300 mb-2" />
<p>Nenhum profissional disponível.</p>
</div>
)}
</div>
</div>
{/* Footer */}
<div className="border-t border-gray-200 p-6 bg-gray-50 flex justify-end gap-3">
<Button
variant="outline"
onClick={closeTeamModal}
>
Fechar
</Button>
</div>
</div>
</div>
)}
{viewingProfessional && (
<ProfessionalDetailsModal
professional={viewingProfessional}
isOpen={!!viewingProfessional}
onClose={() => setViewingProfessional(null)}
/>
)}
{roleSelectionProf && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black bg-opacity-50">
<div className="bg-white rounded-lg p-6 w-96 max-w-full shadow-xl">
<h3 className="text-lg font-bold mb-4">Selecione a Função</h3>
<p className="mb-4 text-gray-600">
Qual função {roleSelectionProf.nome} irá desempenhar neste evento?
</p>
<div className="space-y-2">
{roleSelectionProf.functions?.map((fn) => (
<button
key={fn.id}
onClick={() => handleRoleSelect(fn.id)}
className="w-full text-left px-4 py-3 rounded border hover:bg-gray-50 flex items-center justify-between"
>
<span>{fn.nome}</span>
</button>
))}
</div>
<div className="mt-4 flex justify-end">
<button
onClick={() => setRoleSelectionProf(null)}
className="px-4 py-2 text-gray-600 hover:text-gray-800"
>
Cancelar
</button>
</div>
</div>
</div>
)}
</div>
</div>
);
};