complete overall implementation.
This commit is contained in:
@@ -0,0 +1,339 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Search, MapPin, Truck, Activity, Navigation,
|
||||
User, Calendar, Clock, AlertTriangle, Filter,
|
||||
ChevronDown, X, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight
|
||||
} from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface Vehicle {
|
||||
id: string;
|
||||
registration_number: string;
|
||||
vehicle_type: string;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
station_id?: string;
|
||||
status?: string;
|
||||
gps_lat?: string;
|
||||
gps_lon?: string;
|
||||
activeShift?: any;
|
||||
activeRoster?: any;
|
||||
}
|
||||
|
||||
export const FleetTrips: React.FC = () => {
|
||||
const [vehicles, setVehicles] = useState<Vehicle[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [isSearchFocused, setIsSearchFocused] = useState<boolean>(false);
|
||||
|
||||
// Pagination
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState<number>(5);
|
||||
|
||||
const fetchVehicles = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const token = localStorage.getItem('teleems_token') || '';
|
||||
const res = await fetch('https://teleems-api-gateway.onrender.com/v1/fleet/vehicles', {
|
||||
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
});
|
||||
const json = await res.json();
|
||||
let list: Vehicle[] = [];
|
||||
if (json?.data?.data && Array.isArray(json.data.data)) list = json.data.data;
|
||||
else if (json?.data && Array.isArray(json.data)) list = json.data;
|
||||
else if (Array.isArray(json)) list = json;
|
||||
setVehicles(list);
|
||||
} catch (e: any) {
|
||||
console.error('Failed to fetch vehicles for trip management:', e);
|
||||
setError(e?.message || 'Failed to fetch vehicles');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVehicles();
|
||||
}, [fetchVehicles]);
|
||||
|
||||
const filteredVehicles = vehicles.filter(v => {
|
||||
const searchMatch = v.registration_number?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
v.activeShift?.driver?.user?.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
v.activeShift?.staff?.user?.name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const statusMatch = statusFilter ? v.status === statusFilter : true;
|
||||
return searchMatch && statusMatch;
|
||||
});
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredVehicles.length / itemsPerPage));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const pageData = filteredVehicles.slice((safePage - 1) * itemsPerPage, safePage * itemsPerPage);
|
||||
|
||||
const getStatusColor = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'BUSY':
|
||||
return { bg: 'rgba(239, 68, 68, 0.1)', color: '#EF4444', border: 'rgba(239, 68, 68, 0.2)' };
|
||||
case 'AVAILABLE':
|
||||
return { bg: 'rgba(34, 197, 94, 0.1)', color: '#22C55E', border: 'rgba(34, 197, 94, 0.2)' };
|
||||
default:
|
||||
return { bg: 'rgba(245, 158, 11, 0.1)', color: '#F59E0B', border: 'rgba(245, 158, 11, 0.2)' };
|
||||
}
|
||||
};
|
||||
|
||||
const activeTripsCount = vehicles.filter(v => v.status === 'BUSY').length;
|
||||
const availableCount = vehicles.filter(v => v.status === 'AVAILABLE').length;
|
||||
|
||||
return (
|
||||
<div className="fleet-trips animate-in fade-in duration-500">
|
||||
{/* Top Stats */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(255,255,255,0.05)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#3B82F6' }}><Truck size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#0F172A' }}>{loading ? '...' : vehicles.length}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Total Vehicles</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(239, 68, 68, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#EF4444' }}><Activity size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#EF4444' }}>{loading ? '...' : activeTripsCount}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Active Trips (Busy)</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(34, 197, 94, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#22C55E' }}><Navigation size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#22C55E' }}>{loading ? '...' : availableCount}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>Available Units</div>
|
||||
</div>
|
||||
<div className="glass" style={{ padding: '20px', borderRadius: '16px', border: '1px solid rgba(245, 158, 11, 0.2)', background: '#FFFFFF', boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '12px' }}>
|
||||
<div style={{ color: '#F59E0B' }}><AlertTriangle size={20} /></div>
|
||||
</div>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 900, color: '#F59E0B' }}>{loading ? '...' : vehicles.filter(v => !v.activeShift).length}</div>
|
||||
<div style={{ fontSize: '0.7rem', color: '#64748B', textTransform: 'uppercase', fontWeight: 700 }}>No Active Shift</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center', marginBottom: 16 }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
background: '#FFFFFF',
|
||||
padding: '10px 16px',
|
||||
borderRadius: '12px',
|
||||
border: isSearchFocused ? '1px solid #06B6D4' : '1px solid #CBD5E1',
|
||||
boxShadow: isSearchFocused ? '0 0 0 3px rgba(6, 182, 212, 0.15)' : '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||
transition: 'all 0.2s ease',
|
||||
flex: 1,
|
||||
position: 'relative'
|
||||
}}>
|
||||
<Search size={16} color={isSearchFocused ? "#06B6D4" : "#64748B"} style={{ transition: 'color 0.2s' }} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by vehicle reg, driver, or EMT name..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => { setSearchQuery(e.target.value); setPage(1); }}
|
||||
onFocus={() => setIsSearchFocused(true)}
|
||||
onBlur={() => setIsSearchFocused(false)}
|
||||
className="stations-search-input"
|
||||
style={{ background: 'transparent', border: 'none', color: '#0F172A', fontSize: '0.875rem', outline: 'none', width: '100%', paddingRight: searchQuery ? '24px' : '0' }}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setPage(1); }}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: '12px',
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: '#94A3B8',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '2px',
|
||||
borderRadius: '50%',
|
||||
}}
|
||||
onMouseEnter={e => e.currentTarget.style.color = '#475569'}
|
||||
onMouseLeave={e => e.currentTarget.style.color = '#94A3B8'}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
|
||||
style={{
|
||||
background: '#FFFFFF', border: '1px solid #CBD5E1', padding: '11px 36px 11px 16px',
|
||||
borderRadius: '12px', color: '#0F172A', fontSize: '0.875rem', outline: 'none',
|
||||
cursor: 'pointer', appearance: 'none', minWidth: '180px', boxShadow: '0 2px 4px rgba(15, 23, 42, 0.01)',
|
||||
}}
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="AVAILABLE">Available</option>
|
||||
<option value="BUSY">Busy</option>
|
||||
</select>
|
||||
<ChevronDown size={16} color="#64748B" style={{ position: 'absolute', right: '12px', top: '50%', transform: 'translateY(-50%)', pointerEvents: 'none' }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
<div style={{ padding: '60px', textAlign: 'center', color: '#64748B', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1' }}>
|
||||
<Activity className="spin" size={24} style={{ margin: '0 auto 12px' }} />
|
||||
Loading trips...
|
||||
</div>
|
||||
) : error ? (
|
||||
<div style={{ padding: '24px', background: '#FEF2F2', border: '1px solid #FCA5A5', color: '#991B1B', borderRadius: '16px' }}>
|
||||
<AlertTriangle size={24} style={{ marginBottom: '8px' }} />
|
||||
<div style={{ fontWeight: 600 }}>Error loading trips</div>
|
||||
<div>{error}</div>
|
||||
</div>
|
||||
) : filteredVehicles.length === 0 ? (
|
||||
<div style={{ padding: '60px', textAlign: 'center', color: '#64748B', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1' }}>
|
||||
<MapPin size={48} style={{ opacity: 0.2, margin: '0 auto 16px' }} />
|
||||
<div style={{ fontSize: '1.1rem', fontWeight: 600, color: '#475569' }}>No trips found</div>
|
||||
<div style={{ fontSize: '0.875rem' }}>Adjust your filters to see more results</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gap: '16px' }}>
|
||||
{pageData.map(v => {
|
||||
const sc = getStatusColor(v.status);
|
||||
const shift = v.activeShift;
|
||||
const roster = v.activeRoster;
|
||||
|
||||
const driverName = shift?.driver?.user?.name || roster?.driver?.user?.name || 'Unassigned';
|
||||
const emtName = shift?.staff?.user?.name || roster?.staff?.user?.name || 'Unassigned';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={v.id}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
style={{
|
||||
background: '#FFFFFF', border: '1px solid #CBD5E1', borderRadius: '16px', padding: '20px',
|
||||
display: 'flex', flexDirection: 'column', gap: '16px', borderLeft: `4px solid ${sc.color}`,
|
||||
boxShadow: '0 4px 20px rgba(15, 23, 42, 0.02)'
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
|
||||
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: '#F1F5F9', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#475569' }}>
|
||||
<Truck size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '4px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '1.1rem', fontWeight: 800, color: '#0F172A' }}>{v.registration_number}</h3>
|
||||
<span style={{ fontSize: '0.65rem', padding: '4px 8px', borderRadius: '6px', background: '#F1F5F9', color: '#475569', fontWeight: 700 }}>{v.vehicle_type} UNIT</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#64748B', display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<MapPin size={14} />
|
||||
{v.gps_lat && v.gps_lat !== "0.0000000" ? `${v.gps_lat}, ${v.gps_lon}` : 'Location Unavailable'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '6px 12px', borderRadius: '8px', fontSize: '0.75rem', fontWeight: 800, background: sc.bg, color: sc.color }}>
|
||||
{v.status || 'UNKNOWN'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ height: '1px', background: '#F1F5F9' }} />
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Driver / Pilot</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A', fontWeight: 600 }}>
|
||||
<User size={16} color="#64748B" /> {driverName}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Paramedic / EMT</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A', fontWeight: 600 }}>
|
||||
<User size={16} color="#64748B" /> {emtName}
|
||||
</div>
|
||||
</div>
|
||||
{shift && (
|
||||
<div>
|
||||
<div style={{ fontSize: '0.7rem', fontWeight: 700, color: '#94A3B8', textTransform: 'uppercase', marginBottom: '6px' }}>Shift Details</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#0F172A' }}>
|
||||
<Clock size={16} color="#64748B" />
|
||||
Started: {new Date(shift.startTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{!loading && filteredVehicles.length > 0 && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#FFFFFF', borderRadius: '16px', border: '1px solid #CBD5E1', marginTop: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: '#64748B' }}>Rows per page:</span>
|
||||
<select
|
||||
value={itemsPerPage}
|
||||
onChange={(e) => { setItemsPerPage(Number(e.target.value)); setPage(1); }}
|
||||
style={{ background: '#F8FAFC', border: '1px solid #E2E8F0', borderRadius: '8px', padding: '6px 28px 6px 12px', fontSize: '0.875rem', color: '#0F172A', outline: 'none', cursor: 'pointer', appearance: 'none' }}
|
||||
>
|
||||
{[5, 10, 20, 50].map(size => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
|
||||
<span style={{ fontSize: '0.875rem', color: '#64748B' }}>
|
||||
{Math.min((safePage - 1) * itemsPerPage + 1, filteredVehicles.length)} - {Math.min(safePage * itemsPerPage, filteredVehicles.length)} of {filteredVehicles.length}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
onClick={() => setPage(1)}
|
||||
disabled={safePage === 1}
|
||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === 1 ? '#CBD5E1' : '#64748B', cursor: safePage === 1 ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<ChevronsLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={safePage === 1}
|
||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === 1 ? '#CBD5E1' : '#64748B', cursor: safePage === 1 ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={safePage === totalPages}
|
||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === totalPages ? '#CBD5E1' : '#64748B', cursor: safePage === totalPages ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPage(totalPages)}
|
||||
disabled={safePage === totalPages}
|
||||
style={{ padding: '6px', borderRadius: '6px', border: 'none', background: 'transparent', color: safePage === totalPages ? '#CBD5E1' : '#64748B', cursor: safePage === totalPages ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<ChevronsRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
|
||||
.spin { animation: spin 1s linear infinite; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user