- Implementa filtros de Empresa e Instituição no Dashboard. - Adiciona barra de estatísticas de equipe (fotógrafos, cinegrafistas, recepcionistas) na modal de Gerenciar Equipe. - Corrige bug de atualização da interface após editar evento (mapeamento snake_case). - Adiciona máscaras de input (CPF/CNPJ, Telefone) na página de Perfil. - Corrige ordenação e persistência da listagem de eventos por FOT. - Corrige crash e corrupção de dados na página de Perfil. fix: permite reenviar notificação de logística - Remove bloqueio do botão de notificação de logística quando já enviada. - Altera texto do botão para "Reenviar Notificação" quando aplicável. feat: melhorias no dashboard, perfil e logística - Implementa filtros de Empresa e Instituição no Dashboard. - Adiciona barra de estatísticas de equipe na modal de Gerenciar Equipe. - Desacopla notificação de logística da aprovação do evento (agora apenas manual). - Permite reenviar notificação de logística e remove exibição redundante de data. - Adiciona máscaras de input (CPF/CNPJ, Telefone) no Perfil. - Corrige atualização da interface pós-edição de evento. - Corrige crash, ordenação e persistência na listagem de eventos e perfil.
361 lines
16 KiB
TypeScript
361 lines
16 KiB
TypeScript
import React, { useState, useEffect } from "react";
|
|
import { Plus, Trash, User, Truck, Car, Send, ArrowLeft } from "lucide-react";
|
|
import { useAuth } from "../contexts/AuthContext";
|
|
import { listCarros, createCarro, deleteCarro, addPassenger, removePassenger, listPassengers, listCarros as fetchCarrosApi, notifyLogistics } from "../services/apiService";
|
|
import { useData } from "../contexts/DataContext";
|
|
import { UserRole } from "../types";
|
|
|
|
interface EventLogisticsProps {
|
|
agendaId: string;
|
|
token?: string;
|
|
isEditable?: boolean;
|
|
assignedProfessionals?: string[];
|
|
}
|
|
|
|
interface Carro {
|
|
id: string;
|
|
driver_id: string;
|
|
driver_name: string;
|
|
driver_avatar: string;
|
|
arrival_time: string;
|
|
notes: string;
|
|
passengers: any[]; // We will fetch and attach
|
|
}
|
|
|
|
interface PassengerWithOrder {
|
|
id: string;
|
|
profissional_id: string;
|
|
name: string;
|
|
order: number;
|
|
}
|
|
|
|
const EventLogistics: React.FC<EventLogisticsProps> = ({ agendaId, isEditable: propsIsEditable, assignedProfessionals }) => {
|
|
const { token, user } = useAuth();
|
|
const { professionals, events } = useData();
|
|
const eventData = events.find(e => e.id === agendaId);
|
|
const [carros, setCarros] = useState<Carro[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [passengerOrders, setPassengerOrders] = useState<Record<string, Record<string, number>>>({});
|
|
|
|
// New Car State
|
|
const [driverId, setDriverId] = useState("");
|
|
const [arrivalTime, setArrivalTime] = useState("07:00");
|
|
const [notes, setNotes] = useState("");
|
|
|
|
const isEditable = propsIsEditable !== undefined ? propsIsEditable : (user?.role === UserRole.SUPERADMIN || user?.role === UserRole.BUSINESS_OWNER);
|
|
|
|
// Carregar ordens do localStorage ao montar o componente
|
|
useEffect(() => {
|
|
const savedOrders = localStorage.getItem(`passengerOrders_${agendaId}`);
|
|
if (savedOrders) {
|
|
try {
|
|
setPassengerOrders(JSON.parse(savedOrders));
|
|
} catch (e) {
|
|
console.error("Erro ao carregar ordens salvas:", e);
|
|
}
|
|
}
|
|
}, [agendaId]);
|
|
|
|
// Salvar ordens no localStorage quando mudarem
|
|
useEffect(() => {
|
|
if (Object.keys(passengerOrders).length > 0) {
|
|
localStorage.setItem(`passengerOrders_${agendaId}`, JSON.stringify(passengerOrders));
|
|
}
|
|
}, [passengerOrders, agendaId]);
|
|
|
|
useEffect(() => {
|
|
if (agendaId && token) {
|
|
loadCarros();
|
|
}
|
|
}, [agendaId, token]);
|
|
|
|
const loadCarros = async () => {
|
|
setLoading(true);
|
|
const res = await fetchCarrosApi(agendaId, token!);
|
|
if (res.data) {
|
|
// For each car, fetch passengers
|
|
const carsWithPassengers = await Promise.all(res.data.map(async (car: any) => {
|
|
const passRes = await listPassengers(car.id, token!);
|
|
return { ...car, passengers: passRes.data || [] };
|
|
}));
|
|
setCarros(carsWithPassengers);
|
|
|
|
// Inicializar ordens dos passageiros se não existirem
|
|
const newOrders = { ...passengerOrders };
|
|
carsWithPassengers.forEach((car: any) => {
|
|
if (!newOrders[car.id]) {
|
|
newOrders[car.id] = {};
|
|
}
|
|
car.passengers.forEach((pass: any, index: number) => {
|
|
if (newOrders[car.id][pass.profissional_id] === undefined) {
|
|
newOrders[car.id][pass.profissional_id] = index + 1;
|
|
}
|
|
});
|
|
});
|
|
setPassengerOrders(newOrders);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
const handleAddCarro = async () => {
|
|
// Driver ID is optional (could be external), but for now select from professionals
|
|
const input = {
|
|
agenda_id: agendaId,
|
|
motorista_id: driverId || undefined,
|
|
horario_chegada: arrivalTime,
|
|
observacoes: notes
|
|
};
|
|
const res = await createCarro(input, token!);
|
|
if (res.data) {
|
|
loadCarros();
|
|
setDriverId("");
|
|
setNotes("");
|
|
} else {
|
|
alert("Erro ao criar carro: " + res.error);
|
|
}
|
|
};
|
|
|
|
const handleDeleteCarro = async (id: string) => {
|
|
if (confirm("Remover este carro e passageiros?")) {
|
|
await deleteCarro(id, token!);
|
|
loadCarros();
|
|
}
|
|
};
|
|
|
|
const handleAddPassenger = async (carId: string, profId: string) => {
|
|
if (!profId) return;
|
|
const res = await addPassenger(carId, profId, token!);
|
|
if (!res.error) {
|
|
// Atribuir próxima ordem disponível
|
|
const currentCar = carros.find(c => c.id === carId);
|
|
const currentPassengers = currentCar?.passengers || [];
|
|
const nextOrder = currentPassengers.length + 1;
|
|
|
|
setPassengerOrders(prev => ({
|
|
...prev,
|
|
[carId]: {
|
|
...prev[carId],
|
|
[profId]: nextOrder
|
|
}
|
|
}));
|
|
|
|
loadCarros();
|
|
} else {
|
|
alert("Erro ao adicionar passageiro: " + res.error);
|
|
}
|
|
};
|
|
|
|
const handleRemovePassenger = async (carId: string, profId: string) => {
|
|
const res = await removePassenger(carId, profId, token!);
|
|
if (!res.error) {
|
|
// Remover ordem do passageiro
|
|
setPassengerOrders(prev => {
|
|
const newOrders = { ...prev };
|
|
if (newOrders[carId]) {
|
|
delete newOrders[carId][profId];
|
|
}
|
|
return newOrders;
|
|
});
|
|
loadCarros();
|
|
}
|
|
};
|
|
|
|
const handleChangePassengerOrder = (carId: string, profId: string, newOrder: number) => {
|
|
setPassengerOrders(prev => ({
|
|
...prev,
|
|
[carId]: {
|
|
...prev[carId],
|
|
[profId]: newOrder
|
|
}
|
|
}));
|
|
};
|
|
|
|
const getSortedPassengers = (carId: string, passengers: any[]) => {
|
|
return [...passengers].sort((a, b) => {
|
|
const orderA = passengerOrders[carId]?.[a.profissional_id] || 999;
|
|
const orderB = passengerOrders[carId]?.[b.profissional_id] || 999;
|
|
return orderA - orderB;
|
|
});
|
|
};
|
|
|
|
const handleNotifyLogistics = async () => {
|
|
if (!confirm("Confirmar logística e enviar notificações para TODA a equipe?")) return;
|
|
|
|
const res = await notifyLogistics(token!, agendaId, passengerOrders);
|
|
if (res.error) {
|
|
alert("Erro ao enviar notificações: " + res.error);
|
|
} else {
|
|
alert("Notificações de logística enviadas com sucesso!");
|
|
window.location.reload();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white p-4 rounded-lg shadow space-y-4">
|
|
<div className="flex justify-between items-center">
|
|
<h3 className="text-lg font-semibold text-gray-800 flex items-center">
|
|
<Truck className="w-5 h-5 mr-2 text-orange-500" />
|
|
Logística de Transporte
|
|
</h3>
|
|
|
|
<button
|
|
onClick={() => window.location.href = `/painel?eventId=${agendaId}`}
|
|
className="ml-auto flex items-center gap-2 px-3 py-1.5 bg-white border border-gray-300 rounded text-sm text-gray-700 hover:bg-gray-50 transition-colors shadow-sm"
|
|
>
|
|
<ArrowLeft size={16} />
|
|
Voltar
|
|
</button>
|
|
</div>
|
|
|
|
{/* Add Car Form - Only for Admins */}
|
|
{isEditable && (
|
|
<div className="bg-gray-50 p-3 rounded-md flex flex-wrap gap-2 items-end">
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="text-xs text-gray-500">Motorista (Opcional)</label>
|
|
<select
|
|
className="w-full p-2 rounded border bg-white"
|
|
value={driverId}
|
|
onChange={e => setDriverId(e.target.value)}
|
|
>
|
|
<option value="">Selecione ou deixe vazio...</option>
|
|
{professionals
|
|
.filter(p => {
|
|
const hasCar = p.carro_disponivel;
|
|
const isAssigned = !assignedProfessionals || assignedProfessionals.includes(p.id);
|
|
return hasCar && isAssigned;
|
|
})
|
|
.map(p => (
|
|
<option key={p.id} value={p.id}>{p.nomeEventos || p.nome}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="w-24">
|
|
<label className="text-xs text-gray-500">Chegada</label>
|
|
<input
|
|
type="time"
|
|
className="w-full p-2 rounded border bg-white"
|
|
value={arrivalTime}
|
|
onChange={e => setArrivalTime(e.target.value)}
|
|
/>
|
|
</div>
|
|
<button
|
|
onClick={handleAddCarro}
|
|
className="bg-orange-600 hover:bg-orange-700 text-white p-2 rounded flex items-center"
|
|
>
|
|
<Plus size={20} /> <span className="ml-1 text-sm">Carro</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Cars List */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
{loading ? <p>Carregando...</p> : carros.map(car => (
|
|
<div key={car.id} className="border rounded-lg p-3 bg-gray-50">
|
|
<div className="flex justify-between items-start mb-2 border-b pb-2">
|
|
<div className="flex items-center gap-2">
|
|
<div className="bg-orange-100 p-1.5 rounded-full">
|
|
<Car className="w-4 h-4 text-orange-600" />
|
|
</div>
|
|
<div>
|
|
<p className="font-bold text-sm text-gray-800">
|
|
{car.driver_name || "Motorista não definido"}
|
|
</p>
|
|
<p className="text-xs text-gray-500">Chegada: {car.arrival_time}</p>
|
|
</div>
|
|
</div>
|
|
{isEditable && (
|
|
<button onClick={() => handleDeleteCarro(car.id)} className="text-gray-400 hover:text-red-500">
|
|
<Trash size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Passengers */}
|
|
<div className="space-y-1 mb-3">
|
|
<p className="text-xs font-semibold text-gray-500 uppercase">Passageiros</p>
|
|
{car.passengers.length === 0 && <p className="text-xs italic text-gray-400">Vazio</p>}
|
|
{getSortedPassengers(car.id, car.passengers).map((p: any, index: number) => (
|
|
<div key={p.id} className="flex justify-between items-center text-sm bg-white p-1.5 rounded px-2 border">
|
|
{isEditable ? (
|
|
<div className="flex items-center gap-2 flex-1">
|
|
<select
|
|
className="w-16 text-xs p-1 rounded border bg-gray-50"
|
|
value={passengerOrders[car.id]?.[p.profissional_id] || index + 1}
|
|
onChange={(e) => handleChangePassengerOrder(car.id, p.profissional_id, parseInt(e.target.value))}
|
|
>
|
|
{Array.from({ length: car.passengers.length }, (_, i) => i + 1).map(num => (
|
|
<option key={num} value={num}>{num}º</option>
|
|
))}
|
|
</select>
|
|
<span className="truncate flex-1">{p.name || "Desconhecido"}</span>
|
|
</div>
|
|
) : (
|
|
<span className="truncate">
|
|
{(passengerOrders[car.id]?.[p.profissional_id] || index + 1)}º - {p.name || "Desconhecido"}
|
|
</span>
|
|
)}
|
|
{isEditable && (
|
|
<button onClick={() => handleRemovePassenger(car.id, p.profissional_id)} className="text-red-400 hover:text-red-600 ml-2">
|
|
<Trash size={12} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Add Passenger - Only for Admins */}
|
|
{isEditable && (
|
|
<select
|
|
className="w-full text-xs p-1 rounded border bg-white"
|
|
onChange={(e) => {
|
|
if (e.target.value) handleAddPassenger(car.id, e.target.value);
|
|
e.target.value = "";
|
|
}}
|
|
>
|
|
<option value="">+ Adicionar Passageiro</option>
|
|
{professionals
|
|
.filter(p => {
|
|
// Filter 1: Must be assigned to event (if restriction list provided)
|
|
// If assignedProfessionals prop is missing or empty, maybe we should show all?
|
|
// User asked to RESTRICT. So if provided, WE RESTRICT.
|
|
const isAssigned = !assignedProfessionals || assignedProfessionals.includes(p.id);
|
|
// Filter 2: Must not be already in this car
|
|
const isInCar = car.passengers.some((pass: any) => pass.profissional_id === p.id);
|
|
return isAssigned && !isInCar;
|
|
})
|
|
.map(p => (
|
|
<option key={p.id} value={p.id}>{p.nomeEventos || p.nome}</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Notification Button moved to bottom */}
|
|
{isEditable && (
|
|
<div className="flex flex-col items-center pt-4 border-t border-gray-100">
|
|
<button
|
|
onClick={handleNotifyLogistics}
|
|
className={`px-6 py-2.5 rounded-md flex items-center text-sm font-medium shadow-sm transition-colors w-full justify-center ${
|
|
eventData?.logisticaNotificacaoEnviadaEm
|
|
? "bg-blue-600 hover:bg-blue-700 text-white"
|
|
: "bg-green-600 hover:bg-green-700 text-white"
|
|
}`}
|
|
title={eventData?.logisticaNotificacaoEnviadaEm ? "Reenviar notificação para a equipe" : "Confirmar logística e notificar equipe"}
|
|
>
|
|
<Send className="w-5 h-5 mr-2" />
|
|
{eventData?.logisticaNotificacaoEnviadaEm ? "Reenviar Notificação" : "Finalizar Logística e Notificar Equipe"}
|
|
</button>
|
|
{eventData?.logisticaNotificacaoEnviadaEm && (
|
|
<p className="mt-2 text-xs text-gray-500">
|
|
Última notificação: {new Date(eventData.logisticaNotificacaoEnviadaEm).toLocaleString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default EventLogistics;
|