photum/frontend/components/EventScheduler.tsx
NANDO9322 804a566095 feat(ops): implementa modulo operacional completo (escala, logistica, equipe)
- 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
2025-12-29 16:01:17 -03:00

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;