photum/frontend/pages/DailyLogistics.tsx

574 lines
30 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useData } from '../contexts/DataContext';
import { EventData, EventStatus, Professional } from '../types';
import { RefreshCw, Users, CheckCircle, AlertTriangle, Calendar as CalendarIcon, MapPin, Clock, UserPlus, ChevronLeft, ChevronRight, ChevronDown, ChevronUp } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { getAvailableProfessionalsLogistics, createDailyInvitation, getDailyInvitationsByDate } from '../services/apiService';
import { toast } from 'react-hot-toast';
import { ProfessionalDetailsModal } from '../components/ProfessionalDetailsModal';
import { Car } from 'lucide-react'; // Added Car icon
export const DailyLogistics: React.FC = () => {
const { events, professionals, isLoading, refreshEvents } = useData();
const [selectedDate, setSelectedDate] = useState<string>(new Date().toISOString().split('T')[0]);
const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState("");
const [dailyInvitations, setDailyInvitations] = useState<any[]>([]);
const [isInvitationsExpanded, setIsInvitationsExpanded] = useState(true);
const [viewingProfessional, setViewingProfessional] = useState<Professional | null>(null);
const [isProfModalOpen, setIsProfModalOpen] = useState(false);
// 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];
};
const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDate(e.target.value);
};
const handleRefresh = () => {
refreshEvents();
};
// Filter events by the selected date
const eventsInDay = useMemo(() => {
return events.filter(e => {
if (!e.date) return false;
// e.date might be YYYY-MM-DD or full ISO
const eventDateStr = e.date.split('T')[0];
return eventDateStr === selectedDate;
});
}, [events, selectedDate]);
// Aggregate metrics
const metrics = useMemo(() => {
let totalNeeded = 0;
let totalAccepted = 0;
let totalPending = 0;
// Detailed Role missing counts
let fotMissing = 0;
let recMissing = 0;
let cinMissing = 0;
// Track professionals already counted in events for this day
const eventAcceptedProfIds = new Set<string>();
const eventPendingProfIds = new Set<string>();
eventsInDay.forEach(event => {
// Required professionals
const fotReq = Number(event.qtdFotografos || 0);
const recReq = Number(event.qtdRecepcionistas || 0);
const cinReq = Number(event.qtdCinegrafistas || 0);
const eventTotalNeeded = fotReq + recReq + cinReq;
totalNeeded += eventTotalNeeded;
let fotAc = 0, recAc = 0, cinAc = 0;
let eTotalAc = 0, eTotalPd = 0;
// Count assignments
if (event.assignments) {
event.assignments.forEach(assignment => {
const status = assignment.status ? String(assignment.status).toUpperCase() : "AGUARDANDO";
const prof = professionals.find(p => p.id === assignment.professionalId);
const roleName = prof?.role?.toLowerCase() || "";
if (status === "ACEITO" || status === "CONFIRMADO") {
eTotalAc++;
eventAcceptedProfIds.add(assignment.professionalId);
if (roleName.includes('fot')) fotAc++;
else if (roleName.includes('rece')) recAc++;
else if (roleName.includes('cin')) cinAc++;
else if (assignment.funcaoId === 'fotografo_id') fotAc++;
} else if (status === "AGUARDANDO" || status === "PENDENTE") {
eTotalPd++;
eventPendingProfIds.add(assignment.professionalId);
}
});
}
totalAccepted += eTotalAc;
totalPending += eTotalPd;
// Calculate missing per role for this event
fotMissing += Math.max(0, fotReq - fotAc);
recMissing += Math.max(0, recReq - recAc);
cinMissing += Math.max(0, cinReq - cinAc);
});
// Add Daily Invitations to the generic counters only if not already allocated in an event
const uniqueDailyPending = dailyInvitations.filter(i => i.status === 'PENDENTE' && !eventPendingProfIds.has(i.profissional_id) && !eventAcceptedProfIds.has(i.profissional_id)).length;
const uniqueDailyAccepted = dailyInvitations.filter(i => i.status === 'ACEITO' && !eventAcceptedProfIds.has(i.profissional_id)).length;
totalPending += uniqueDailyPending;
totalAccepted += uniqueDailyAccepted;
const totalMissing = Math.max(0, totalNeeded - totalAccepted);
return {
totalNeeded, totalAccepted, totalPending, totalMissing,
fotMissing, recMissing, cinMissing
};
}, [eventsInDay, professionals, dailyInvitations]);
const [paginatedProfessionals, setPaginatedProfessionals] = useState<any[]>([]);
const [totalPros, setTotalPros] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [isProsLoading, setIsProsLoading] = useState(false);
// Helper: Fetch available professionals from API
const fetchPros = async () => {
setIsProsLoading(true);
try {
const resp = await getAvailableProfessionalsLogistics(selectedDate, page, 50, searchTerm);
setPaginatedProfessionals(resp.data || []);
setTotalPros(resp.total || 0);
setTotalPages(resp.total_pages || 1);
} catch (err) {
console.error("Failed to fetch professionals", err);
toast.error("Erro ao carregar profissionais");
} finally {
setIsProsLoading(false);
}
};
const fetchDailyInvitations = async () => {
try {
const res = await getDailyInvitationsByDate(selectedDate);
setDailyInvitations(res || []);
} catch(err) {
console.error(err);
}
};
useEffect(() => {
fetchPros();
fetchDailyInvitations();
}, [selectedDate, page, searchTerm]);
// Select Event Context Handlers -> Now it's Daily Allocation!
const allocDaily = async (profId: string) => {
try {
await createDailyInvitation(profId, selectedDate);
toast.success("Convite de diária enviado!");
fetchPros(); // Refresh list to remove from available
fetchDailyInvitations(); // refresh the pending invitations section
} catch (err: any) {
toast.error(err.message || "Erro ao alocar diária");
}
};
// Render individual event card
const renderEventCard = (event: EventData) => {
const fotReq = Number(event.qtdFotografos || 0);
const recReq = Number(event.qtdRecepcionistas || 0);
const cinReq = Number(event.qtdCinegrafistas || 0);
const needed = fotReq + recReq + cinReq;
// Group accepted/pending counts by role directly from event's assignments
// We assume assignments map back to the professional's functions or are inherently mapped
let accepted = 0;
let pending = 0;
let fotAc = 0, recAc = 0, cinAc = 0;
event.assignments?.forEach(a => {
const status = a.status ? String(a.status).toUpperCase() : "AGUARDANDO";
const prof = professionals.find(p => p.id === a.professionalId);
const roleName = prof?.role?.toLowerCase() || "";
if (status === "ACEITO" || status === "CONFIRMADO") {
accepted++;
if (roleName.includes('fot')) fotAc++;
else if (roleName.includes('rece')) recAc++;
else if (roleName.includes('cin')) cinAc++;
else if (a.funcaoId === 'fotografo_id') fotAc++; // Fallback maps
}
else if (status === "AGUARDANDO" || status === "PENDENTE") pending++;
});
const missing = Math.max(0, needed - accepted);
return (
<div
key={event.id}
onClick={() => navigate(`/painel?eventId=${event.id}`)}
className="bg-white rounded-xl shadow-sm border border-gray-100 p-5 hover:shadow-md hover:border-brand-purple/50 transition-all cursor-pointer group"
>
<div className="flex justify-between items-start mb-4">
<div className="flex-1">
<h3 className="font-bold text-gray-900 group-hover:text-brand-purple transition-colors line-clamp-1 flex items-center gap-2">
{event.name}
<ChevronRight size={16} className="text-transparent group-hover:text-brand-purple transition-colors" />
</h3>
<div className="flex items-center text-xs text-gray-500 mt-2 gap-4">
<span className="flex items-center gap-1"><Clock size={14}/> {event.time}</span>
<span className="flex items-center gap-1 truncate max-w-[150px]"><MapPin size={14}/> {event.address?.city || "Cidade N/A"}</span>
</div>
</div>
<span className={`px-2 py-1 rounded-md text-xs font-semibold shrink-0 ml-2 ${missing > 0 ? 'bg-red-50 text-red-600' : 'bg-green-50 text-green-600'}`}>
{missing > 0 ? `Faltam ${missing}` : 'Completo'}
</span>
</div>
{/* Roles Breakdown */}
<div className="bg-gray-50 rounded-lg p-3 text-xs flex flex-col gap-2 border border-gray-100 mb-4">
<div className="flex justify-between items-center text-gray-600 font-medium pb-1 border-b border-gray-200">
<span>Função</span>
<div className="flex gap-4">
<span className="w-12 text-center" title="Aceitos / Necessários">A / N</span>
</div>
</div>
{fotReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Fotógrafo</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${fotAc < fotReq ? 'text-yellow-600' : 'text-green-600'}`}>
{fotAc} / {fotReq}
</span>
</div>
</div>
)}
{recReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Recepcionista</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${recAc < recReq ? 'text-yellow-600' : 'text-green-600'}`}>
{recAc} / {recReq}
</span>
</div>
</div>
)}
{cinReq > 0 && (
<div className="flex justify-between items-center">
<span className="text-gray-700">Cinegrafista</span>
<div className="flex gap-4 text-center">
<span className={`w-12 font-semibold ${cinAc < cinReq ? 'text-yellow-600' : 'text-green-600'}`}>
{cinAc} / {cinReq}
</span>
</div>
</div>
)}
{needed === 0 && (
<div className="text-gray-400 italic text-center py-1">Nenhuma vaga registrada</div>
)}
</div>
{Array.isArray(event.assignments) && event.assignments.length > 0 && (
<div className="mt-2 text-xs">
<div className="flex justify-between text-gray-500 mb-1">
<span>Total de Convites:</span>
<span className="font-semibold">{event.assignments.length}</span>
</div>
{pending > 0 && (
<div className="flex justify-between text-yellow-600">
<span>Aguardando resposta:</span>
<span className="font-semibold">{pending}</span>
</div>
)}
</div>
)}
</div>
);
};
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 pt-24 animate-fade-in">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">Logística Diária</h1>
<p className="text-gray-500">Acompanhamento e escala de equipe por data da agenda</p>
</div>
<div className="flex items-center gap-3">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<CalendarIcon size={18} className="text-gray-400" />
</div>
<input
type="date"
value={selectedDate}
onChange={handleDateChange}
className="pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple"
/>
</div>
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 bg-white border border-gray-300 rounded-lg text-gray-600 hover:bg-gray-50 hover:text-brand-purple transition-all"
title="Atualizar dados"
>
<RefreshCw size={20} className={isLoading ? "animate-spin" : ""} />
</button>
</div>
</div>
{/* Dash/Metrics Panel */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-blue-50 text-blue-600 rounded-lg"><Users size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Necessários</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalNeeded}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-green-50 text-green-600 rounded-lg"><CheckCircle size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Aceitos</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalAccepted}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-gray-100 flex items-center gap-4">
<div className="p-3 bg-yellow-50 text-yellow-600 rounded-lg"><RefreshCw size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Pendentes</p>
<p className="text-2xl font-bold text-gray-900">{metrics.totalPending}</p>
</div>
</div>
<div className="bg-white rounded-xl p-6 shadow-sm border border-red-200 flex flex-col justify-center">
<div className="flex items-center gap-4 mb-2">
<div className="p-3 bg-red-50 text-red-600 rounded-lg"><AlertTriangle size={24}/></div>
<div>
<p className="text-sm text-gray-500 font-medium uppercase">Faltam (Geral)</p>
<p className="text-2xl font-bold text-red-600">{metrics.totalMissing}</p>
</div>
</div>
{/* Detailed Missing Roles */}
{metrics.totalMissing > 0 && (
<div className="flex flex-wrap gap-2 text-xs border-t border-red-100 pt-3 mt-1">
{metrics.fotMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Fotógrafos: {metrics.fotMissing}</span>
)}
{metrics.recMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Recepcionistas: {metrics.recMissing}</span>
)}
{metrics.cinMissing > 0 && (
<span className="bg-red-50 text-red-600 px-2 py-1 rounded-md font-medium">Cinegrafistas: {metrics.cinMissing}</span>
)}
</div>
)}
</div>
</div>
{/* Daily Invitations Section */}
{dailyInvitations.length > 0 && (
<div className="mb-6 bg-white rounded-xl shadow-sm border border-gray-100 p-5">
<div
className="flex justify-between items-center cursor-pointer mb-1 hover:bg-gray-50 p-2 -mx-2 rounded-lg transition-colors"
onClick={() => setIsInvitationsExpanded(!isInvitationsExpanded)}
>
<h3 className="font-bold text-gray-900 flex items-center gap-2">
<Users size={18} className="text-brand-purple" /> Convites Enviados ({dailyInvitations.length})
</h3>
<div className="text-gray-400 hover:text-brand-purple transition-colors">
{isInvitationsExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</div>
</div>
{isInvitationsExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 mt-3">
{dailyInvitations.map((inv: any) => {
const prof = professionals.find(p => p.id === inv.profissional_id);
return (
<div
key={inv.id}
className="flex flex-col sm:flex-row sm:items-center justify-between bg-gray-50 border border-gray-100 rounded-lg p-3 gap-2 cursor-pointer hover:border-brand-purple/40 hover:shadow-sm transition-all"
onClick={() => {
if (prof) {
setViewingProfessional(prof);
setIsProfModalOpen(true);
}
}}
>
<div className="flex items-center gap-3">
<img src={prof?.avatar_url || `https://ui-avatars.com/api/?name=${prof?.nome || 'P'}&background=random`} alt={prof?.nome} className="w-8 h-8 rounded-full" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-semibold text-sm text-gray-900 leading-tight truncate max-w-[120px]" title={prof?.nome || 'Desconhecido'}>{prof?.nome || 'Desconhecido'}</p>
{prof?.carro_disponivel && (
<Car size={13} className="text-gray-400 shrink-0" title="Possui Carro" />
)}
</div>
<p className="text-[10px] text-brand-gold uppercase tracking-wider truncate">{prof?.role || 'Profissional'}</p>
{prof?.cidade && (
<span className={`inline-block mt-0.5 px-1.5 py-0.5 text-[9px] uppercase font-bold tracking-widest rounded border ${getCityColor(prof.cidade)}`}>
{prof.cidade}
</span>
)}
</div>
</div>
<span className={`px-2 py-1 text-[10px] font-bold rounded uppercase tracking-wider self-start sm:self-auto ${
inv.status === 'ACEITO' ? 'bg-green-100 text-green-700' :
inv.status === 'REJEITADO' ? 'bg-red-100 text-red-700' :
'bg-yellow-100 text-yellow-700'
}`}>
{inv.status}
</span>
</div>
)
})}
</div>
)}
</div>
)}
{/* Layout with Events (Left) and Available Professionals Sidebar (Right) */}
<div className="flex flex-col lg:flex-row gap-8">
{/* Left Column: Events */}
<div className="flex-1">
{isLoading ? (
<div className="flex justify-center py-20">
<RefreshCw size={32} className="text-brand-purple animate-spin" />
</div>
) : eventsInDay.length === 0 ? (
<div className="text-center py-20 bg-white rounded-2xl border border-dashed border-gray-300">
<CalendarIcon size={48} className="mx-auto text-gray-300 mb-4" />
<h3 className="text-lg font-medium text-gray-900">Nenhum evento nesta data</h3>
<p className="text-gray-500 mt-1">Selecione outro dia no calendário acima.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{eventsInDay.map(renderEventCard)}
</div>
)}
</div>
{/* Right Column: Sticky Sidebar for Professionals */}
<div className="w-full lg:w-96 flex-shrink-0">
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 sticky top-28 flex flex-col h-[calc(100vh-140px)]">
<div className="p-5 border-b border-gray-100 bg-brand-purple/5 rounded-t-2xl">
<h2 className="text-lg font-bold text-brand-purple flex items-center gap-2">
<Users size={20} /> Lista Geral de Disponíveis
</h2>
<p className="text-sm text-gray-600 mt-1">
Profissionais com status livre no dia {selectedDate.split('-').reverse().join('/')}
</p>
</div>
<div className="p-4 border-b border-gray-100">
<input
type="text"
placeholder="Buscar pelo nome..."
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-brand-purple outline-none"
/>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{isProsLoading ? (
<div className="flex justify-center items-center h-full">
<RefreshCw size={24} className="animate-spin text-brand-purple" />
</div>
) : paginatedProfessionals.length === 0 ? (
<div className="text-center py-10 opacity-60">
<UserPlus size={40} className="mx-auto mb-3 text-gray-400" />
<p className="text-sm text-gray-500">Nenhum profissional disponível.</p>
<p className="text-xs text-gray-400 mt-1">Todos ocupados ou não encontrados.</p>
</div>
) : (
paginatedProfessionals.map(prof => (
<div key={prof.id} className="flex flex-col gap-2 p-3 bg-gray-50 border border-gray-100 rounded-xl hover:bg-white hover:border-brand-purple/30 transition-colors">
<div className="flex items-center gap-3">
<div
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
onClick={() => {
setViewingProfessional(prof);
setIsProfModalOpen(true);
}}
>
<img src={prof.avatar_url || `https://ui-avatars.com/api/?name=${prof.nome}&background=random`} alt={prof.nome} className="w-10 h-10 rounded-full shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-bold text-gray-900 text-sm truncate">{prof.nome}</p>
{prof.carro_disponivel && (
<Car size={14} className="text-gray-500 shrink-0" title="Possui Carro" />
)}
</div>
<p className="text-xs text-brand-gold truncate">{prof.role || "Profissional"}</p>
{prof.cidade && (
<span className={`inline-block mt-1 px-2 py-0.5 text-[10px] uppercase font-bold tracking-wider rounded border ${getCityColor(prof.cidade)}`}>
{prof.cidade}
</span>
)}
</div>
</div>
<button
onClick={() => allocDaily(prof.id)}
className="px-4 py-2 bg-[#6E2C90] text-white font-bold rounded-lg text-xs hover:bg-[#5a2375] transition-colors whitespace-nowrap shadow-md border border-[#5a2375]"
>
Alocar
</button>
</div>
</div>
))
)}
</div>
{/* Pagination Context */}
{totalPages > 1 && (
<div className="p-3 bg-gray-50 border-t border-gray-100 flex justify-between items-center rounded-b-2xl">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
>
<ChevronLeft size={16} />
</button>
<span className="text-xs text-gray-500 font-medium">Página {page} de {totalPages}</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-1 rounded bg-white border border-gray-300 disabled:opacity-50"
>
<ChevronRight size={16} />
</button>
</div>
)}
</div>
</div>
</div>
{/* Modal Profissionais */}
{isProfModalOpen && viewingProfessional && (
<ProfessionalDetailsModal
professional={viewingProfessional}
isOpen={isProfModalOpen}
onClose={() => {
setIsProfModalOpen(false);
setViewingProfessional(null);
}}
/>
)}
</div>
);
};