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:
parent
90e1508409
commit
1bfdc689d0
1 changed files with 141 additions and 70 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue