Backend:
- Corrigido mapeamento de email na listagem e busca por ID de profissionais. Agora o sistema utiliza o email do usuário vinculado (`usuario_email`) como fallback caso o email do perfil profissional esteja vazio.
Frontend:
- EventScheduler: Implementado filtro estrito para exibir apenas profissionais que foram explicitamente adicionados à equipe do evento ("Gerenciar Equipe"), prevenindo escalações indevidas.
- EventScheduler: Adicionada validação para ocultar cargos administrativos sem função operacional definida.
- ProfessionalDetailsModal: Corrigida a lógica de exibição do avatar para suportar a propriedade `avatar_url` (padrão atual do backend), resolvendo o problema de imagens quebradas ou ícones genéricos.
321 lines
16 KiB
TypeScript
321 lines
16 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, getFunctions } from "../services/apiService";
|
|
import { useData } from "../contexts/DataContext";
|
|
import { UserRole } from "../types";
|
|
|
|
interface EventSchedulerProps {
|
|
agendaId: string;
|
|
dataEvento: string; // YYYY-MM-DD
|
|
allowedProfessionals?: { professional_id?: string; professionalId?: string; status?: string }[] | string[]; // IDs or Objects
|
|
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 [roles, setRoles] = useState<{ id: string; nome: string }[]>([]);
|
|
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 for THIS exact time?
|
|
// Wait, 'events.photographerIds' means they are on the Team.
|
|
// Being on the Team uses generic time?
|
|
// For now, assume busy if in another event team.
|
|
|
|
// Fix for Request 2: Only consider BUSY if status is 'Confirmado' in the other event?
|
|
// The frontend 'events' list might generally show all events.
|
|
// But 'events' from 'useData' implies basic info.
|
|
// If we access specific assigned status here, we could filter.
|
|
// The `events` array usually has basic info. If `assigned_professionals` is detailed there, we could check status.
|
|
// Assuming `e.photographerIds` is just IDs.
|
|
// We'll leave backend to strictly enforce, but frontend hint is good.
|
|
|
|
// Check if professional is in any other event on the same day with overlap
|
|
// FIX: Only consider BUSY if status is 'ACEITO' (Confirmed)
|
|
const isAssignedConfirmed = (e.assignments || []).some(a => a.professionalId === profId && a.status === 'ACEITO');
|
|
|
|
if (e.date === dataEvento && isAssignedConfirmed) {
|
|
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();
|
|
getFunctions().then(res => {
|
|
if (res.data) setRoles(res.data);
|
|
});
|
|
}
|
|
}, [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
|
|
// FILTER: Only show professionals with a valid role (Function), matching "Equipe" page logic.
|
|
let availableProfs = professionals.filter(p => roles.some(r => r.id === p.funcao_profissional_id));
|
|
const allowedMap = new Map<string, string>(); // ID -> Status
|
|
|
|
if (allowedProfessionals) {
|
|
// Normalize allowed list
|
|
const ids: string[] = [];
|
|
allowedProfessionals.forEach((p: any) => {
|
|
if (typeof p === 'string') {
|
|
ids.push(p);
|
|
allowedMap.set(p, 'Confirmado'); // Default if not detailed
|
|
} else {
|
|
const pid = p.professional_id || p.professionalId;
|
|
const status = p.status || 'Pendente';
|
|
// Filter out Rejected professionals from the available list
|
|
if (pid && status !== 'REJEITADO' && status !== 'Rejeitado') {
|
|
ids.push(pid);
|
|
allowedMap.set(pid, status);
|
|
}
|
|
}
|
|
});
|
|
availableProfs = availableProfs.filter(p => ids.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);
|
|
const status = allowedMap.get(p.id);
|
|
const isPending = status !== 'Confirmado' && status !== 'ACEITO';
|
|
const isDisabled = isBusy || isPending;
|
|
|
|
let label = "";
|
|
if (isPending) label = "(Pendente de Aceite)";
|
|
else if (isBusy) label = "(Ocupado)";
|
|
|
|
return (
|
|
<option key={p.id} value={p.id} disabled={isDisabled} className={isDisabled ? "text-gray-400" : ""}>
|
|
{p.nome} - {p.role || "Profissional"} {label}
|
|
</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;
|