- db: criada migration para adicionar coluna `horario_fim` na tabela agenda - backend: queries SQLC atualizadas para ler e gravar horario_fim - backend: mapeamento no service.go modificado para incluir e retornar o horário - backend: atualizada documentação das rotas (Swagger) - frontend/ui: adicionado campo de input Horário de Término no EventForm - frontend/ui: painéis do Dashboard e DailyLogistics renderizando o novo formato visual de exibição de horas do evento - frontend/logic: atualizada validação de profissionais ocupados (busyProfessionalIds) para analisar colisão real com base no intervalo Início x Fim em vez do fechamento total do dia - frontend/context: conserto no state global do DataContext para não perder a string do backend após edições via modal
574 lines
30 KiB
TypeScript
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.startTime || event.time}{event.endTime || event.horario_fim ? ` às ${event.endTime || event.horario_fim}` : ''}</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>
|
|
);
|
|
};
|