photum/frontend/components/EventScheduler.tsx
NANDO9322 7a06d4e691 feat(agenda): migração busca POI para Google Maps e correção do horário de término
Frontend:
- Migração da API de Geocoding do Mapbox para o Google Maps Places API (googleMapsService.ts) no formulário de eventos, garantindo a busca correta pelo nome de locais (estádios, teatros) e com autopreenchimento.
- Correção do fluxo de estado do 'horario_fim', propagando e persistindo o 'endTime' pelo DataContext, garantindo a população dos dados na edição do EventForm.
- Adição da visualização do horário final na listagem do Dashboard, no EventCard, painéis de EventDetails e atualização das props defaultEndTime no EventScheduler.

Backend:
- Atualização e migração dos arquivos gerados pelo sqlc (models.go, agenda.sql.go) para suportar operações no novo design do banco.
- Atualização síncrona dos artefatos Swagger de documentação de API (docs.go, swagger.json, swagger.yaml).
2026-02-27 18:48:07 -03:00

339 lines
17 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;
defaultEndTime?: 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, defaultEndTime }) => {
const { token, user } = useAuth();
const { professionals, events, functions } = useData();
const [escalas, setEscalas] = useState<any[]>([]);
const roles = functions;
const [loading, setLoading] = useState(false);
// New entry state
const [selectedProf, setSelectedProf] = useState("");
const [startTime, setStartTime] = useState(defaultTime || "08:00");
const [endTime, setEndTime] = useState(defaultEndTime || "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;
const canViewSchedule = user?.role !== UserRole.EVENT_OWNER; // EVENT_OWNER can't see schedule details
// 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();
// Functions handled via context
}
}, [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
const assignedRoleMap = new Map<string, string>(); // ID -> Role Name
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);
// Use assigned role ID (handle both casing)
const fId = p.funcaoId || p.funcao_id;
if (fId) {
const r = roles.find(role => role.id === fId);
if (r) assignedRoleMap.set(pid, r.nome);
}
}
}
});
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;
const assignedRole = assignedRoleMap.get(p.id);
// Resolve role name from ID if not assigned specifically
const defaultRole = roles.find(r => r.id === p.funcao_profissional_id)?.nome;
const displayRole = assignedRole || defaultRole || (p as any).funcao_nome || p.role || "Profissional";
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} - {displayRole} {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);
const assignedRole = assignedRoleMap.get(item.profissional_id);
// Resolve role name
const defaultProfRole = profData ? roles.find(r => r.id === profData.funcao_profissional_id)?.nome : undefined;
const displayRole = assignedRole || item.profissional_role || defaultProfRole || (profData as any)?.funcao_nome || profData?.role;
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}
{displayRole && <span className="ml-1 text-xs text-gray-500 font-normal">({displayRole})</span>}
{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;