- Backend: Migrations para tabelas 'escalas' e 'logistica' (transporte) - Backend: Handlers e Services completos para gestão de escalas e logística - Backend: Suporte a auth vinculado a perfil profissional - Frontend: Nova página de Detalhes Operacionais (/agenda/:id) - Frontend: Componente EventScheduler com verificação robusta de conflitos - Frontend: Componente EventLogistics para gestão de motoristas e caronas - Frontend: Modal de Detalhes de Profissional unificado (Admin + Self-view) - Frontend: Dashboard com modal de gestão de equipe e filtros avançados - Fix: Correção crítica de timezone (UTC) em horários de agendamento - Fix: Tratamento de URLs no campo de local do evento - Fix: Filtros de profissional com carro na logística
279 lines
14 KiB
TypeScript
279 lines
14 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Plus, Trash, User, Truck, MapPin } from "lucide-react";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { listEscalas, createEscala, deleteEscala, EscalaInput } from "../services/apiService";
|
|
import { useData } from "../contexts/DataContext";
|
|
import { UserRole } from "../types";
|
|
|
|
interface EventSchedulerProps {
|
|
agendaId: string;
|
|
dataEvento: string; // YYYY-MM-DD
|
|
allowedProfessionals?: string[]; // IDs of professionals allowed to be scheduled
|
|
onUpdateStats?: (stats: { studios: number }) => void;
|
|
defaultTime?: string;
|
|
}
|
|
|
|
const timeSlots = [
|
|
"07:00", "08:00", "09:00", "10:00", "11:00", "12:00",
|
|
"13:00", "14:00", "15:00", "16:00", "17:00", "18:00",
|
|
"19:00", "20:00", "21:00", "22:00", "23:00", "00:00"
|
|
];
|
|
|
|
const EventScheduler: React.FC<EventSchedulerProps> = ({ agendaId, dataEvento, allowedProfessionals, onUpdateStats, defaultTime }) => {
|
|
const { token, user } = useAuth();
|
|
const { professionals, events } = useData();
|
|
const [escalas, setEscalas] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
// New entry state
|
|
const [selectedProf, setSelectedProf] = useState("");
|
|
const [startTime, setStartTime] = useState(defaultTime || "08:00");
|
|
const [endTime, setEndTime] = useState("12:00"); // Could calculated based on start, but keep simple
|
|
const [role, setRole] = useState("");
|
|
|
|
const isEditable = user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER;
|
|
|
|
// Helper to check availability
|
|
const checkAvailability = (profId: string) => {
|
|
// Parse current request time in minutes
|
|
const [h, m] = startTime.split(':').map(Number);
|
|
const reqStart = h * 60 + m;
|
|
const reqEnd = reqStart + 240; // +4 hours
|
|
|
|
// Check if professional is in any other event on the same day with overlap
|
|
const conflict = events.some(e => {
|
|
if (e.id === agendaId) return false; // Ignore current event (allow re-scheduling or moving within same event?)
|
|
// Actually usually we don't want to double book in same event either unless intention is specific.
|
|
// But 'escalas' check (Line 115) already handles "already in this scale".
|
|
// If they are assigned to the *Event Team* (Logistics) but not Scale yet, it doesn't mean they are busy at THIS exact time?
|
|
// Wait, 'events.photographerIds' means they are on the Team.
|
|
// Being on the Team for specific time?
|
|
// Does 'EventData' have time? Yes. 'e.time'.
|
|
// If they are in another Event Team, we assume they are busy for that Event's duration.
|
|
|
|
if (e.date === dataEvento && e.photographerIds.includes(profId)) {
|
|
const [eh, em] = (e.time || "00:00").split(':').map(Number);
|
|
const evtStart = eh * 60 + em;
|
|
const evtEnd = evtStart + 240; // Assume 4h duration for other events too
|
|
|
|
// Overlap check
|
|
return (reqStart < evtEnd && evtStart < reqEnd);
|
|
}
|
|
return false;
|
|
});
|
|
return conflict;
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (agendaId && token) {
|
|
fetchEscalas();
|
|
}
|
|
}, [agendaId, token]);
|
|
|
|
// Recalculate stats whenever scales change
|
|
useEffect(() => {
|
|
if (onUpdateStats && escalas.length >= 0) {
|
|
let totalStudios = 0;
|
|
escalas.forEach(item => {
|
|
const prof = professionals.find(p => p.id === item.profissional_id);
|
|
if (prof && prof.qtd_estudio) {
|
|
totalStudios += prof.qtd_estudio;
|
|
}
|
|
});
|
|
onUpdateStats({ studios: totalStudios });
|
|
}
|
|
}, [escalas, professionals, onUpdateStats]);
|
|
|
|
const fetchEscalas = async () => {
|
|
setLoading(true);
|
|
const result = await listEscalas(agendaId, token!);
|
|
if (result.data) {
|
|
setEscalas(result.data);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleAddEscala = async () => {
|
|
if (!selectedProf || !startTime) return;
|
|
|
|
// Create Date objects from Local Time
|
|
const localStart = new Date(`${dataEvento}T${startTime}:00`);
|
|
const localEnd = new Date(localStart);
|
|
localEnd.setHours(localEnd.getHours() + 4);
|
|
|
|
// Convert to UTC ISO String and format for backend (Space, no ms)
|
|
// toISOString returns YYYY-MM-DDTHH:mm:ss.sssZ
|
|
const startISO = localStart.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
|
|
const endISO = localEnd.toISOString().replace('T', ' ').replace(/\.\d{3}Z$/, 'Z');
|
|
|
|
const input: EscalaInput = {
|
|
agenda_id: agendaId,
|
|
profissional_id: selectedProf,
|
|
data_hora_inicio: startISO,
|
|
data_hora_fim: endISO,
|
|
funcao_especifica: role
|
|
};
|
|
|
|
const res = await createEscala(input, token!);
|
|
if (res.data) {
|
|
fetchEscalas();
|
|
setSelectedProf("");
|
|
setRole("");
|
|
} else {
|
|
alert("Erro ao criar escala: " + res.error);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: string) => {
|
|
if (confirm("Remover profissional da escala?")) {
|
|
await deleteEscala(id, token!);
|
|
fetchEscalas();
|
|
}
|
|
};
|
|
|
|
// 1. Start with all professionals or just the allowed ones
|
|
let availableProfs = professionals;
|
|
if (allowedProfessionals && allowedProfessionals.length > 0) {
|
|
availableProfs = availableProfs.filter(p => allowedProfessionals.includes(p.id));
|
|
}
|
|
|
|
// 2. Filter out professionals already in schedule to prevent duplicates
|
|
// But keep the currently selected one valid if it was just selected
|
|
availableProfs = availableProfs.filter(p => !escalas.some(e => e.profissional_id === p.id));
|
|
|
|
const selectedProfessionalData = professionals.find(p => p.id === selectedProf);
|
|
|
|
return (
|
|
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
|
<MapPin className="w-5 h-5 mr-2 text-indigo-500" />
|
|
Escala de Profissionais
|
|
</h3>
|
|
|
|
{/* Warning if restricting and empty */}
|
|
{isEditable && allowedProfessionals && allowedProfessionals.length === 0 && (
|
|
<div className="bg-yellow-50 text-yellow-800 text-sm p-3 rounded border border-yellow-200">
|
|
Nenhum profissional atribuído a este evento. Adicione membros à equipe antes de criar a escala.
|
|
</div>
|
|
)}
|
|
|
|
{/* Add Form - Only for Admins */}
|
|
{isEditable && (
|
|
<div className="bg-gray-50 p-3 rounded-md space-y-3">
|
|
<div className="flex flex-wrap gap-2 items-end">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="text-xs text-gray-500">Profissional</label>
|
|
<select
|
|
className="w-full p-2 rounded border bg-white"
|
|
value={selectedProf}
|
|
onChange={(e) => setSelectedProf(e.target.value)}
|
|
>
|
|
<option value="">Selecione...</option>
|
|
{availableProfs.map(p => {
|
|
const isBusy = checkAvailability(p.id);
|
|
return (
|
|
<option key={p.id} value={p.id} disabled={isBusy} className={isBusy ? "text-gray-400" : ""}>
|
|
{p.nome} - {p.role || "Profissional"} {isBusy ? "(Ocupado em outro evento)" : ""}
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
</div>
|
|
<div className="w-24">
|
|
<label className="text-xs text-gray-500">Início</label>
|
|
<input
|
|
type="time"
|
|
className="w-full p-2 rounded border bg-white"
|
|
value={startTime}
|
|
onChange={(e) => setStartTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-[150px]">
|
|
<label className="text-xs text-gray-500">Função (Opcional)</label>
|
|
<input
|
|
type="text"
|
|
placeholder="Ex: Palco"
|
|
className="w-full p-2 rounded border bg-white"
|
|
value={role}
|
|
onChange={(e) => setRole(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleAddEscala}
|
|
className="bg-green-600 hover:bg-green-700 text-white p-2 rounded flex items-center"
|
|
>
|
|
<Plus size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Equipment Info Preview */}
|
|
{selectedProfessionalData && (selectedProfessionalData.equipamentos || selectedProfessionalData.qtd_estudio > 0) && (
|
|
<div className="text-xs text-gray-500 bg-white p-2 rounded border border-dashed border-gray-300">
|
|
<span className="font-semibold">Equipamentos:</span> {selectedProfessionalData.equipamentos || "Nenhum cadastrado"}
|
|
{selectedProfessionalData.qtd_estudio > 0 && (
|
|
<span className="ml-3 text-indigo-600 font-semibold">• Possui {selectedProfessionalData.qtd_estudio} Estúdio(s)</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Timeline / List */}
|
|
<div className="space-y-2">
|
|
{loading ? <p>Carregando...</p> : escalas.length === 0 ? (
|
|
<p className="text-gray-500 text-sm italic">Nenhuma escala definida.</p>
|
|
) : (
|
|
escalas.map(item => {
|
|
// Find professional data again to show equipment in list if needed
|
|
// Ideally backend should return it, but for now we look up in global list if available
|
|
const profData = professionals.find(p => p.id === item.profissional_id);
|
|
|
|
return (
|
|
<div key={item.id} className="flex flex-col p-2 hover:bg-gray-50 rounded border-b">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-full bg-gray-200 overflow-hidden flex-shrink-0">
|
|
{item.avatar_url ? (
|
|
<img src={item.avatar_url} alt="" className="w-full h-full object-cover" />
|
|
) : (
|
|
<User className="w-6 h-6 m-2 text-gray-400" />
|
|
)}
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-gray-800">
|
|
{item.profissional_nome}
|
|
{item.phone && <span className="ml-2 text-xs text-gray-500 font-normal">({item.phone})</span>}
|
|
</p>
|
|
<p className="text-xs text-gray-500">
|
|
{new Date(item.start).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
|
|
{new Date(item.end).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
{item.role && <span className="ml-2 bg-blue-100 text-blue-800 px-1 rounded text-[10px]">{item.role}</span>}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{isEditable && (
|
|
<button
|
|
onClick={() => handleDelete(item.id)}
|
|
className="text-red-500 hover:text-red-700 p-1"
|
|
>
|
|
<Trash size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
{/* Show equipment if available */}
|
|
{profData && profData.equipamentos && (
|
|
<div className="ml-14 mt-1 text-[10px] text-gray-400">
|
|
<span className="font-bold">Equip:</span> {profData.equipamentos}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EventScheduler;
|