perf: otimização crítica no modal de equipe e correção de requests duplicados

- Implementada paginação (Load More) na lista de profissionais para suportar grandes volumes de dados sem travar a UI.
- Otimização do cálculo de disponibilidade (busy check) de O(N*M) para O(N) usando Sets pré-calculados.
- Adicionado estado de processamento nos botões de ação para prevenir cliques múltiplos e requisições duplicadas.
- Refatoração dos filtros para uso de `useMemo`, evitando re-renderizações desnecessárias.
This commit is contained in:
NANDO9322 2026-02-04 16:46:37 -03:00
parent 90e1508409
commit 1bfdc689d0

View file

@ -211,12 +211,39 @@ export const Dashboard: React.FC<DashboardProps> = ({
setTeamAvailabilityFilter("all"); setTeamAvailabilityFilter("all");
}; };
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 // Função para filtrar profissionais no modal de equipe
const getFilteredTeamProfessionals = () => { const filteredTeamProfessionals = useMemo(() => {
if (!selectedEvent) return professionals; if (!selectedEvent) return professionals;
// Reset page when filters change
setTeamPage(1);
return professionals.filter((professional) => { return professionals.filter((professional) => {
// Filter out professionals with unknown roles (matches Team.tsx logic) // Filter out professionals with unknown roles
if (functions.length > 0) { if (functions.length > 0) {
const isValidRole = functions.some(f => f.id === professional.funcao_profissional_id); const isValidRole = functions.some(f => f.id === professional.funcao_profissional_id);
if (!isValidRole) return false; if (!isValidRole) return false;
@ -233,63 +260,57 @@ export const Dashboard: React.FC<DashboardProps> = ({
// Filtro por função/role // Filtro por função/role
if (teamRoleFilter !== "all") { if (teamRoleFilter !== "all") {
const professionalFunctions = professional.functions || []; const professionalFunctions = professional.functions || [];
// Check if professional has the selected function ID in their list
const hasMatchingFunction = professionalFunctions.some(f => f.id === teamRoleFilter); const hasMatchingFunction = professionalFunctions.some(f => f.id === teamRoleFilter);
// Also check if professional.funcao_profissional_id matches (primary role)
const hasMatchingPrimaryRole = professional.funcao_profissional_id === teamRoleFilter; const hasMatchingPrimaryRole = professional.funcao_profissional_id === teamRoleFilter;
if (!hasMatchingFunction && !hasMatchingPrimaryRole) return false; if (!hasMatchingFunction && !hasMatchingPrimaryRole) return false;
} }
// Verificar status do assignment para este evento // 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( const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === professional.id (a) => a.professionalId === professional.id
); );
const status = assignment ? assignment.status : null; const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO"; const isAssigned = !!status && status !== "REJEITADO";
// Verificar se está ocupado em outro evento na mesma data // Otimização 2: Usar o Set pré-calculado (O(1))
const isBusy = !isAssigned && events.some(e => const isBusy = !isAssigned && busyProfessionalIds.has(professional.id);
e.id !== selectedEvent.id &&
e.date === selectedEvent.date &&
(e.assignments || []).some(a => a.professionalId === professional.id && a.status === 'ACEITO')
);
// Filtro por status // Filtro por status
if (teamStatusFilter !== "all") { if (teamStatusFilter !== "all") {
switch (teamStatusFilter) { switch (teamStatusFilter) {
case "assigned": case "assigned": if (!isAssigned) return false; break;
if (!isAssigned) return false; case "available": if (isAssigned || isBusy) return false; break;
break; case "busy": if (!isBusy) return false; break;
case "available": case "rejected": if (!status || status !== "REJEITADO") return false; break;
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 // Filtro por disponibilidade
if (teamAvailabilityFilter !== "all") { if (teamAvailabilityFilter !== "all") {
switch (teamAvailabilityFilter) { switch (teamAvailabilityFilter) {
case "available": case "available": if (isAssigned || isBusy) return false; break;
if (isAssigned || isBusy) return false; case "unavailable": if (!isAssigned && !isBusy) return false; break;
break;
case "unavailable":
if (!isAssigned && !isBusy) return false;
break;
} }
} }
return true; return true;
}); });
}; }, [
selectedEvent,
professionals,
functions,
teamSearchTerm,
teamRoleFilter,
teamStatusFilter,
teamAvailabilityFilter,
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 // Guard Clause for basic security
if (!user) if (!user)
@ -473,8 +494,10 @@ export const Dashboard: React.FC<DashboardProps> = ({
setIsTeamModalOpen(true); setIsTeamModalOpen(true);
}; };
const togglePhotographer = (photographerId: string) => { const togglePhotographer = async (photographerId: string) => {
if (!selectedEvent) return; if (!selectedEvent) return;
if (processingIds.has(photographerId)) return; // Prevent double click
const prof = professionals.find(p => p.id === photographerId); const prof = professionals.find(p => p.id === photographerId);
const assignment = selectedEvent.assignments?.find(a => a.professionalId === photographerId); const assignment = selectedEvent.assignments?.find(a => a.professionalId === photographerId);
@ -483,13 +506,31 @@ export const Dashboard: React.FC<DashboardProps> = ({
return; return;
} }
assignPhotographer(selectedEvent.id, photographerId); 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 = (funcaoId: string) => { const handleRoleSelect = async (funcaoId: string) => {
if (roleSelectionProf && selectedEvent) { if (roleSelectionProf && selectedEvent) {
assignPhotographer(selectedEvent.id, roleSelectionProf.id, funcaoId); setProcessingIds(prev => new Set(prev).add(roleSelectionProf.id));
setRoleSelectionProf(null); try {
await assignPhotographer(selectedEvent.id, roleSelectionProf.id, funcaoId);
} finally {
setProcessingIds(prev => {
const next = new Set(prev);
next.delete(roleSelectionProf.id);
return next;
});
setRoleSelectionProf(null);
}
} }
}; };
@ -1461,21 +1502,31 @@ export const Dashboard: React.FC<DashboardProps> = ({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{getFilteredTeamProfessionals().map((photographer) => { {displayedTeamProfessionals.map((photographer) => {
const assignment = (selectedEvent.assignments || []).find( const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === photographer.id (a) => a.professionalId === photographer.id
); );
const status = assignment ? assignment.status : null; const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO"; const isAssigned = !!status && status !== "REJEITADO";
// Check if busy in other events on the same date // Optimization: Use busySet (O(1))
const busyEvent = !isAssigned ? events.find(e => // 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.id !== selectedEvent.id &&
e.date === selectedEvent.date && e.date === selectedEvent.date &&
(e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO') (e.assignments || []).some(a => a.professionalId === photographer.id && a.status === 'ACEITO')
) : undefined; ) : undefined;
const isBusy = !!busyEvent;
const isProcessing = processingIds.has(photographer.id);
return ( return (
<tr <tr
@ -1557,36 +1608,45 @@ export const Dashboard: React.FC<DashboardProps> = ({
onClick={() => onClick={() =>
togglePhotographer(photographer.id) togglePhotographer(photographer.id)
} }
disabled={!status && isBusy} disabled={(!status && isBusy) || isProcessing}
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${status === "ACEITO" || status === "PENDENTE" 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-red-100 text-red-700 hover:bg-red-200"
: "bg-brand-gold text-white hover:bg-[#a5bd2e]" : "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"} {status === "ACEITO" || status === "PENDENTE" ? "Remover" : "Adicionar"}
</button> </button>
</td> </td>
</tr> </tr>
); );
})} })}
{getFilteredTeamProfessionals().length === 0 && (
{/* Empty State */}
{filteredTeamProfessionals.length === 0 && (
<tr> <tr>
<td colSpan={5} className="p-8 text-center"> <td colSpan={5} className="p-8 text-center">
<div className="flex flex-col items-center gap-3"> {/* ... existing empty state ... */}
<div className="flex flex-col items-center gap-3">
<UserX size={48} className="text-gray-300" /> <UserX size={48} className="text-gray-300" />
<p className="text-gray-500 font-medium"> <p className="text-gray-500 font-medium">
{professionals.length === 0 Ninguém encontrado.
? "Nenhum profissional disponível para esta data"
: "Nenhum profissional encontrado com os filtros aplicados"
}
</p> </p>
<p className="text-sm text-gray-400"> </div>
{professionals.length === 0 </td>
? "Tente selecionar outra data ou entre em contato com a equipe" </tr>
: "Tente ajustar os filtros de busca" )}
}
</p> {/* Load More Row */}
</div> {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> </td>
</tr> </tr>
)} )}
@ -1596,18 +1656,16 @@ export const Dashboard: React.FC<DashboardProps> = ({
{/* Lista de Cards (Mobile) */} {/* Lista de Cards (Mobile) */}
<div className="md:hidden space-y-4"> <div className="md:hidden space-y-4">
{getFilteredTeamProfessionals().map((photographer) => { {displayedTeamProfessionals.map((photographer) => {
const assignment = (selectedEvent.assignments || []).find( const assignment = (selectedEvent.assignments || []).find(
(a) => a.professionalId === photographer.id (a) => a.professionalId === photographer.id
); );
const status = assignment ? assignment.status : null; const status = assignment ? assignment.status : null;
const isAssigned = !!status && status !== "REJEITADO";
// Check if busy in other events on the same date const isBusyInSet = busyProfessionalIds.has(photographer.id);
const isBusy = !status && events.some(e => const isBusy = !isAssigned && isBusyInSet;
e.id !== selectedEvent.id && const isProcessing = processingIds.has(photographer.id);
e.date === selectedEvent.date &&
e.photographerIds.includes(photographer.id)
);
return ( return (
<div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm"> <div key={photographer.id} className="bg-white border border-gray-200 rounded-lg p-4 shadow-sm">
@ -1667,19 +1725,32 @@ export const Dashboard: React.FC<DashboardProps> = ({
</button> </button>
<button <button
onClick={() => togglePhotographer(photographer.id)} onClick={() => togglePhotographer(photographer.id)}
disabled={!status && isBusy} disabled={(!status && isBusy) || isProcessing}
className={`flex-1 py-2 rounded-lg text-sm font-medium transition-colors ${status === "ACEITO" || status === "PENDENTE" 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" ? "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]" : 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"} {status === "ACEITO" || status === "PENDENTE" ? "Remover" : isBusy ? "Ocupado" : "Adicionar"}
</button> </button>
</div> </div>
</div> </div>
); );
})} })}
{professionals.length === 0 && (
{/* 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"> <div className="text-center p-8 text-gray-500">
<UserX size={48} className="mx-auto text-gray-300 mb-2" /> <UserX size={48} className="mx-auto text-gray-300 mb-2" />
<p>Nenhum profissional disponível.</p> <p>Nenhum profissional disponível.</p>