Added new Sorting of Fleet Overview
This commit is contained in:
@@ -96,7 +96,7 @@ export default function Home() {
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
<div className="lg:col-span-2 space-y-8">
|
<div className="lg:col-span-2 space-y-8">
|
||||||
<VehicleList vehicles={vehicles} />
|
<VehicleList vehicles={vehicles} groups={groups} />
|
||||||
<UpdateManager
|
<UpdateManager
|
||||||
updates={updates}
|
updates={updates}
|
||||||
vehicles={vehicles}
|
vehicles={vehicles}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("VehicleList Component", () => {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = VehicleList({ vehicles });
|
const result = VehicleList({ vehicles, groups: [] });
|
||||||
expect(result).toBeDefined();
|
expect(result).toBeDefined();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If it fails on hook or DOM, that's expected in this environment
|
// If it fails on hook or DOM, that's expected in this environment
|
||||||
|
|||||||
@@ -1,79 +1,212 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Vehicle } from '@/lib/api';
|
import { Vehicle, VehicleGroup } from '@/lib/api';
|
||||||
import { getVehicleDisplayStatus } from '@/lib/utils';
|
import { getVehicleDisplayStatus } from '@/lib/utils';
|
||||||
import { Car, Clock, Signal, Layers } from 'lucide-react';
|
import { Car, Clock, Signal, Layers, ChevronDown, ChevronRight, ChevronsDown, ChevronsUp } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface VehicleListProps {
|
interface VehicleListProps {
|
||||||
vehicles: Vehicle[];
|
vehicles: Vehicle[];
|
||||||
|
groups?: VehicleGroup[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VehicleList({ vehicles }: VehicleListProps) {
|
export function VehicleList({ vehicles, groups = [] }: VehicleListProps) {
|
||||||
return (
|
// State for tracking collapsed groups. If a key is present and true, it is collapsed.
|
||||||
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl">
|
// Default (empty object) means all are expanded.
|
||||||
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2">
|
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||||
<Car className="w-5 h-5 text-indigo-400" />
|
|
||||||
Fleet Overview
|
|
||||||
</h2>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-left">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800 text-zinc-400 text-sm">
|
|
||||||
<th className="pb-3 pl-2">VIN</th>
|
|
||||||
<th className="pb-3">Group</th>
|
|
||||||
<th className="pb-3">Status</th>
|
|
||||||
<th className="pb-3">Version</th>
|
|
||||||
<th className="pb-3">Last Heartbeat</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-zinc-800/50">
|
|
||||||
{vehicles.map((vehicle) => {
|
|
||||||
// Calculate status: Offline if no heartbeat for 5s
|
|
||||||
const displayStatus = getVehicleDisplayStatus(vehicle);
|
|
||||||
|
|
||||||
return (
|
const groupedVehicles = useMemo(() => {
|
||||||
<tr key={vehicle.vin} className="text-zinc-300 hover:bg-zinc-800/30 transition-colors">
|
const grouped: Record<number | 'unassigned', Vehicle[]> = { 'unassigned': [] };
|
||||||
<td className="py-3 pl-2 font-mono text-sm">{vehicle.vin}</td>
|
// Initialize groups
|
||||||
<td className="py-3">
|
groups.forEach(g => {
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-zinc-800 text-zinc-400">
|
grouped[g.id] = [];
|
||||||
<Layers className="w-3 h-3" />
|
});
|
||||||
{vehicle.group?.name || 'Unassigned'}
|
|
||||||
</span>
|
// Distribute vehicles
|
||||||
</td>
|
vehicles.forEach(v => {
|
||||||
<td className="py-3">
|
if (v.groupId && grouped[v.groupId]) {
|
||||||
<span className={clsx(
|
grouped[v.groupId].push(v);
|
||||||
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
} else {
|
||||||
displayStatus === 'Online' && "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20",
|
grouped['unassigned'].push(v);
|
||||||
displayStatus === 'Offline' && "bg-red-500/10 text-red-400 border border-red-500/20",
|
}
|
||||||
displayStatus === 'Updating' && "bg-amber-500/10 text-amber-400 border border-amber-500/20 animate-pulse"
|
});
|
||||||
)}>
|
|
||||||
<Signal className="w-3 h-3" />
|
return grouped;
|
||||||
{displayStatus}
|
}, [vehicles, groups]);
|
||||||
</span>
|
|
||||||
</td>
|
const getGroupStats = (groupVehicles: Vehicle[]) => {
|
||||||
<td className="py-3">
|
const online = groupVehicles.filter(v => getVehicleDisplayStatus(v) === 'Online').length;
|
||||||
<span className="font-mono text-zinc-400 bg-zinc-900 px-2 py-1 rounded border border-zinc-800">
|
const total = groupVehicles.length;
|
||||||
v{vehicle.currentVersion}
|
const versions = groupVehicles.reduce((acc, v) => {
|
||||||
</span>
|
acc[v.currentVersion] = (acc[v.currentVersion] || 0) + 1;
|
||||||
</td>
|
return acc;
|
||||||
<td className="py-3 text-sm text-zinc-500 flex items-center gap-1.5">
|
}, {} as Record<string, number>);
|
||||||
<Clock className="w-3 h-3" />
|
|
||||||
{new Date(vehicle.lastHeartbeat).toLocaleTimeString()}
|
const versionStr = Object.entries(versions)
|
||||||
</td>
|
.map(([v, count]) => `v${v} (${Math.round((count / total) * 100)}%)`)
|
||||||
</tr>
|
.join(', ');
|
||||||
)
|
|
||||||
})}
|
return { online, total, versionStr };
|
||||||
{vehicles.length === 0 && (
|
};
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center py-8 text-zinc-500">
|
// Helper to get group details
|
||||||
No vehicles registered yet.
|
const getGroupDetails = (key: string) => {
|
||||||
</td>
|
if (key === 'unassigned') return { name: 'Unassigned', description: 'Vehicles without a group' };
|
||||||
</tr>
|
const group = groups.find(g => g.id === Number(key));
|
||||||
)}
|
return group || { name: 'Unknown Group', description: '' };
|
||||||
</tbody>
|
};
|
||||||
</table>
|
|
||||||
|
const toggleGroup = (key: string) => {
|
||||||
|
setCollapsedGroups(prev => ({
|
||||||
|
...prev,
|
||||||
|
[key]: !prev[key]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupKeys = Object.keys(groupedVehicles).filter(k =>
|
||||||
|
k === 'unassigned' ? groupedVehicles[k].length > 0 : true
|
||||||
|
).sort((a, b) => {
|
||||||
|
if (a === 'unassigned') return 1;
|
||||||
|
if (b === 'unassigned') return -1;
|
||||||
|
return 0; // Keep order
|
||||||
|
});
|
||||||
|
|
||||||
|
const expandAll = () => setCollapsedGroups({});
|
||||||
|
const collapseAll = () => {
|
||||||
|
const allCollapsed = groupKeys.reduce((acc, key) => {
|
||||||
|
acc[key] = true;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, boolean>);
|
||||||
|
setCollapsedGroups(allCollapsed);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl space-y-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||||
|
<Car className="w-5 h-5 text-indigo-400" />
|
||||||
|
Fleet Overview
|
||||||
|
</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={expandAll}
|
||||||
|
className="p-1.5 hover:bg-zinc-800 rounded-md text-zinc-400 hover:text-white transition-colors"
|
||||||
|
title="Expand All"
|
||||||
|
>
|
||||||
|
<ChevronsDown className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={collapseAll}
|
||||||
|
className="p-1.5 hover:bg-zinc-800 rounded-md text-zinc-400 hover:text-white transition-colors"
|
||||||
|
title="Collapse All"
|
||||||
|
>
|
||||||
|
<ChevronsUp className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{groupKeys.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-zinc-500 bg-zinc-900 rounded-lg border border-zinc-800">
|
||||||
|
No groups or vehicles found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{groupKeys.map(key => {
|
||||||
|
const groupVs = groupedVehicles[key as any];
|
||||||
|
if (key !== 'unassigned' && groupVs.length === 0) {
|
||||||
|
// Show empty groups too? Requirement implies showing group info.
|
||||||
|
// If 0 vehicles, stats are 0/0 and versions empty.
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, description } = getGroupDetails(key);
|
||||||
|
const stats = getGroupStats(groupVs);
|
||||||
|
const isCollapsed = collapsedGroups[key];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={key} className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 py-3 border-b border-zinc-800/50 cursor-pointer hover:bg-zinc-800/20 rounded-lg px-2 -mx-2 transition-colors select-none"
|
||||||
|
onClick={() => toggleGroup(key)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-zinc-500">
|
||||||
|
{isCollapsed ? <ChevronRight className="w-5 h-5" /> : <ChevronDown className="w-5 h-5" />}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-zinc-200 flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-zinc-500" />
|
||||||
|
{name}
|
||||||
|
</h3>
|
||||||
|
{description && <p className="text-sm text-zinc-500 ml-6">{description}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-sm pl-8 sm:pl-0">
|
||||||
|
<span className="bg-zinc-800 text-zinc-300 px-3 py-1 rounded-full border border-zinc-700">
|
||||||
|
{stats.online}/{stats.total} Online
|
||||||
|
</span>
|
||||||
|
{stats.total > 0 && (
|
||||||
|
<span className="text-zinc-500 hidden sm:inline-block">
|
||||||
|
{stats.versionStr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<div className="pl-4 border-l border-zinc-800/50 ml-2">
|
||||||
|
{groupVs.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800 text-zinc-400 text-sm">
|
||||||
|
<th className="pb-3 pl-2 w-1/4">VIN</th>
|
||||||
|
<th className="pb-3 w-1/4">Status</th>
|
||||||
|
<th className="pb-3 w-1/4">Version</th>
|
||||||
|
<th className="pb-3 w-1/4">Last Heartbeat</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800/50">
|
||||||
|
{groupVs.map((vehicle) => {
|
||||||
|
const displayStatus = getVehicleDisplayStatus(vehicle);
|
||||||
|
return (
|
||||||
|
<tr key={vehicle.vin} className="text-zinc-300 hover:bg-zinc-800/30 transition-colors">
|
||||||
|
<td className="py-3 pl-2 font-mono text-sm">{vehicle.vin}</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span className={clsx(
|
||||||
|
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
|
||||||
|
displayStatus === 'Online' && "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20",
|
||||||
|
displayStatus === 'Offline' && "bg-red-500/10 text-red-400 border border-red-500/20",
|
||||||
|
displayStatus === 'Updating' && "bg-amber-500/10 text-amber-400 border border-amber-500/20 animate-pulse"
|
||||||
|
)}>
|
||||||
|
<Signal className="w-3 h-3" />
|
||||||
|
{displayStatus}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3">
|
||||||
|
<span className="font-mono text-zinc-400 bg-zinc-900 px-2 py-1 rounded border border-zinc-800">
|
||||||
|
v{vehicle.currentVersion}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-sm text-zinc-500 flex items-center gap-1.5">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{new Date(vehicle.lastHeartbeat).toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="py-4 text-center text-zinc-600 text-sm italic bg-zinc-900/30 rounded border border-dashed border-zinc-800">
|
||||||
|
No vehicles in this group.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user