273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
import React, { useState } from 'react';
|
|
import { MapPin, Truck, AlertTriangle, Navigation, Filter, Search, RefreshCw, Radio, Gauge, Clock, X, ChevronRight, History as HistoryIcon, Layers } from 'lucide-react';
|
|
import { useTelemetry } from '../hooks/useTelemetry';
|
|
|
|
const Modal = ({ isOpen, onClose, title, children }) => {
|
|
if (!isOpen) return null;
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-300">
|
|
<div className="bg-white dark:bg-[#1b1b1b] w-full max-w-2xl rounded-[32px] border border-slate-200 dark:border-white/5 shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
|
|
<div className="flex items-center justify-between p-6 border-b border-slate-100 dark:border-white/5">
|
|
<h3 className="text-xl font-bold text-slate-800 dark:text-white">{title}</h3>
|
|
<button onClick={onClose} className="p-2 hover:bg-slate-100 dark:hover:bg-white/5 rounded-full transition-colors text-slate-400">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
<div className="p-8 max-h-[80vh] overflow-y-auto">
|
|
{children}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const GrTelemetryView = () => {
|
|
const [selectedClient, setSelectedClient] = useState('ALL');
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [activeVehicle, setActiveVehicle] = useState(null);
|
|
const [historyData, setHistoryData] = useState([]);
|
|
const [isHistoryModalOpen, setIsHistoryModalOpen] = useState(false);
|
|
const [isHistoryLoading, setIsHistoryLoading] = useState(false);
|
|
|
|
const { vehicles, alerts, loading, error, refresh, getVehicleHistory } = useTelemetry();
|
|
|
|
const filteredVehicles = vehicles.filter(vehicle => {
|
|
const matchesClient = selectedClient === 'ALL' || vehicle.client === selectedClient;
|
|
const matchesSearch = vehicle.plate.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
vehicle.driver.toLowerCase().includes(searchTerm.toLowerCase());
|
|
return matchesClient && matchesSearch;
|
|
});
|
|
|
|
const getRiskColor = (status) => {
|
|
switch (status) {
|
|
case 'CRITICAL': return 'bg-red-500 text-white';
|
|
case 'HIGH': return 'bg-orange-500 text-white';
|
|
case 'NORMAL': return 'bg-green-500 text-white';
|
|
default: return 'bg-slate-500 text-white';
|
|
}
|
|
};
|
|
|
|
const handleShowHistory = async (plate) => {
|
|
setIsHistoryLoading(true);
|
|
setIsHistoryModalOpen(true);
|
|
try {
|
|
const history = await getVehicleHistory(plate);
|
|
setHistoryData(history);
|
|
} catch (err) {
|
|
alert('Erro ao carregar histórico');
|
|
} finally {
|
|
setIsHistoryLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleShowOnMap = (plate) => {
|
|
alert(`Focando veículo ${plate} no mapa dinâmico...`);
|
|
};
|
|
|
|
const clients = ['ALL', 'PRALOG', 'PROFARMA', 'PETY', 'MERCADO_LIVRE'];
|
|
|
|
return (
|
|
<div className="flex flex-col gap-8 p-6 md:p-10 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
{/* Header */}
|
|
<header className="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
|
<div className="space-y-2">
|
|
<h1 className="text-4xl md:text-6xl font-bold text-slate-800 dark:text-white tracking-tighter leading-none">
|
|
Telemetria <span className="text-[var(--gr-primary)]">& Rastreamento</span>
|
|
</h1>
|
|
<p className="text-slate-400 dark:text-slate-500 text-base md:text-lg font-medium italic opacity-80">
|
|
Monitoramento de posições em tempo real e alertas de geofencing
|
|
</p>
|
|
</div>
|
|
|
|
<button
|
|
onClick={refresh}
|
|
disabled={loading}
|
|
className="flex items-center gap-2 px-6 py-3 bg-[var(--gr-primary)] text-white rounded-full font-bold text-[10px] uppercase tracking-widest hover:bg-[var(--gr-primary)]/90 transition-all disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} />
|
|
Atualizar
|
|
</button>
|
|
</header>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
<div className="p-4 bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5">
|
|
<div className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-2">Total Veículos</div>
|
|
<div className="text-3xl font-bold text-slate-800 dark:text-white">{vehicles.length}</div>
|
|
</div>
|
|
<div className="p-4 bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5">
|
|
<div className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-2">Ativos</div>
|
|
<div className="text-3xl font-bold text-green-600">{vehicles.filter(v => v.status === 'ATIVO').length}</div>
|
|
</div>
|
|
<div className="p-4 bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5">
|
|
<div className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-2">Alertas</div>
|
|
<div className="text-3xl font-bold text-red-600">{alerts.length}</div>
|
|
</div>
|
|
<div className="p-4 bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5">
|
|
<div className="text-[10px] font-bold uppercase tracking-widest text-slate-400 mb-2">Críticos</div>
|
|
<div className="text-3xl font-bold text-red-600">{vehicles.filter(v => v.riskStatus === 'CRITICAL').length}</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filtros */}
|
|
<div className="flex flex-wrap gap-4 items-center justify-between">
|
|
<div className="flex p-2 bg-slate-100 dark:bg-[#141414] rounded-[24px] border border-slate-200 dark:border-white/5 shadow-inner overflow-x-auto">
|
|
{clients.map((client) => (
|
|
<button
|
|
key={client}
|
|
onClick={() => setSelectedClient(client)}
|
|
className={`px-6 py-2 rounded-[18px] text-[10px] font-bold uppercase tracking-widest transition-all whitespace-nowrap ${
|
|
selectedClient === client
|
|
? 'bg-white dark:bg-[#1b1b1b] text-slate-900 dark:text-white shadow-md'
|
|
: 'text-slate-500 hover:text-slate-700'
|
|
}`}
|
|
>
|
|
{client === 'ALL' ? 'Todos' : client.replace('_', ' ')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<div className="relative group">
|
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-[var(--gr-primary)] transition-colors" size={16} />
|
|
<input
|
|
type="text"
|
|
placeholder="BUSCAR PLACA..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
className="pl-10 pr-4 py-2 bg-white dark:bg-[#141414] border border-slate-200 dark:border-white/5 rounded-full text-[10px] font-bold focus:outline-none focus:ring-4 focus:ring-[var(--gr-primary)]/10 transition-all uppercase outline-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Lista de Veículos */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20">
|
|
<RefreshCw size={48} className="text-[var(--gr-primary)] animate-spin" />
|
|
</div>
|
|
) : filteredVehicles.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 gap-6">
|
|
<div className="w-24 h-24 bg-[var(--gr-primary)]/10 rounded-full flex items-center justify-center">
|
|
<MapPin size={48} className="text-[var(--gr-primary)]" />
|
|
</div>
|
|
<div className="text-center space-y-2">
|
|
<h3 className="text-2xl font-bold text-slate-800 dark:text-white">Nenhum veículo encontrado</h3>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="grid md:grid-cols-2 gap-4">
|
|
{filteredVehicles.map((vehicle) => (
|
|
<div
|
|
key={vehicle.id}
|
|
className="p-6 bg-white dark:bg-[#1b1b1b] rounded-2xl border border-slate-200 dark:border-white/5 hover:border-[var(--gr-primary)]/20 transition-all"
|
|
>
|
|
<div className="flex items-start justify-between mb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<Truck size={20} className="text-[var(--gr-primary)]" />
|
|
<h3 className="text-xl font-bold text-slate-800 dark:text-white">{vehicle.plate}</h3>
|
|
</div>
|
|
<p className="text-sm text-slate-500">{vehicle.driver}</p>
|
|
</div>
|
|
<span className={`px-3 py-1 rounded-full text-xs font-bold ${getRiskColor(vehicle.riskStatus)}`}>
|
|
{vehicle.riskStatus}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
|
<MapPin size={16} className="text-[var(--gr-primary)]" />
|
|
<span className="truncate font-medium">{vehicle.address}</span>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="p-3 bg-slate-50 dark:bg-[#141414] rounded-2xl flex items-center gap-3 border border-slate-100 dark:border-white/5">
|
|
<Gauge size={18} className="text-slate-400" />
|
|
<div>
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase">Velocidade</p>
|
|
<p className="text-sm font-bold text-slate-800 dark:text-white">{vehicle.speed} km/h</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-3 bg-slate-50 dark:bg-[#141414] rounded-2xl flex items-center gap-3 border border-slate-100 dark:border-white/5">
|
|
<Radio size={18} className="text-slate-400" />
|
|
<div>
|
|
<p className="text-[8px] font-bold text-slate-400 uppercase">Provedor</p>
|
|
<p className="text-sm font-bold text-slate-800 dark:text-white">{vehicle.provider}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{vehicle.alert && (
|
|
<div className="flex items-center gap-3 p-3 bg-red-500/10 rounded-2xl border border-red-500/20 text-red-600">
|
|
<AlertTriangle size={18} />
|
|
<span className="text-[10px] font-bold uppercase tracking-wider">{vehicle.alert.replace('_', ' ')}</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
onClick={() => handleShowOnMap(vehicle.plate)}
|
|
className="flex-1 px-4 py-3 bg-[var(--gr-primary)] text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-[var(--gr-primary)]/90 transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<Layers size={14} />
|
|
Focar no Mapa
|
|
</button>
|
|
<button
|
|
onClick={() => handleShowHistory(vehicle.plate)}
|
|
className="flex-1 px-4 py-3 bg-slate-100 dark:bg-[#141414] text-slate-800 dark:text-white rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-slate-200 dark:hover:bg-[#1f1f1f] transition-all flex items-center justify-center gap-2"
|
|
>
|
|
<HistoryIcon size={14} />
|
|
Histórico
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal de Histórico */}
|
|
<Modal
|
|
isOpen={isHistoryModalOpen}
|
|
onClose={() => setIsHistoryModalOpen(false)}
|
|
title={`Histórico de Posições - ${filteredVehicles.find(v => v.id === activeVehicle)?.plate || ''}`}
|
|
>
|
|
<div className="space-y-4">
|
|
{isHistoryLoading ? (
|
|
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
|
<RefreshCw size={32} className="text-[var(--gr-primary)] animate-spin" />
|
|
<p className="text-xs font-bold text-slate-400 uppercase">Carregando trajeto...</p>
|
|
</div>
|
|
) : historyData.length === 0 ? (
|
|
<div className="text-center py-12">
|
|
<p className="text-slate-500">Nenhum histórico disponível para este veículo.</p>
|
|
</div>
|
|
) : (
|
|
<div className="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-100 dark:before:bg-white/5">
|
|
{historyData.map((pos, i) => (
|
|
<div key={i} className="relative flex items-center justify-between group">
|
|
<div className="absolute -left-[19px] w-2 h-2 rounded-full border-2 border-[var(--gr-primary)] bg-white dark:bg-[#1b1b1b] z-10" />
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-bold text-slate-800 dark:text-white">
|
|
Lat: {pos.lat.toFixed(4)} • Lng: {pos.lng.toFixed(4)}
|
|
</p>
|
|
<p className="text-[10px] text-slate-400 font-medium">
|
|
{new Date(pos.timestamp).toLocaleString('pt-BR')}
|
|
</p>
|
|
</div>
|
|
<div className="px-3 py-1 bg-slate-50 dark:bg-[#141414] rounded-lg border border-slate-100 dark:border-white/5">
|
|
<span className="text-[10px] font-bold text-[var(--gr-primary)]">{pos.speed}km/h</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default GrTelemetryView;
|