From fdbcec16ed177cbb2601db7128c2926e3706f27f Mon Sep 17 00:00:00 2001 From: Denis Urs Rudolph Date: Thu, 11 Dec 2025 21:28:36 +0100 Subject: [PATCH] Added new Sorting of Fleet Overview --- app/page.tsx | 2 +- components/VehicleList.test.tsx | 2 +- components/VehicleList.tsx | 265 ++++++++++++++++++++++++-------- 3 files changed, 201 insertions(+), 68 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index f0c7c35..b116842 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -96,7 +96,7 @@ export default function Home() { {/* Main Content */}
- + { }]; try { - const result = VehicleList({ vehicles }); + const result = VehicleList({ vehicles, groups: [] }); expect(result).toBeDefined(); } catch (e) { // If it fails on hook or DOM, that's expected in this environment diff --git a/components/VehicleList.tsx b/components/VehicleList.tsx index 434bdf4..e20b5dd 100644 --- a/components/VehicleList.tsx +++ b/components/VehicleList.tsx @@ -1,79 +1,212 @@ -'use client'; -import { Vehicle } from '@/lib/api'; +import { Vehicle, VehicleGroup } from '@/lib/api'; 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 { useMemo, useState } from 'react'; interface VehicleListProps { vehicles: Vehicle[]; + groups?: VehicleGroup[]; } -export function VehicleList({ vehicles }: VehicleListProps) { - return ( -
-

- - Fleet Overview -

-
- - - - - - - - - - - - {vehicles.map((vehicle) => { - // Calculate status: Offline if no heartbeat for 5s - const displayStatus = getVehicleDisplayStatus(vehicle); +export function VehicleList({ vehicles, groups = [] }: VehicleListProps) { + // State for tracking collapsed groups. If a key is present and true, it is collapsed. + // Default (empty object) means all are expanded. + const [collapsedGroups, setCollapsedGroups] = useState>({}); - return ( - - - - - - - - ) - })} - {vehicles.length === 0 && ( - - - - )} - -
VINGroupStatusVersionLast Heartbeat
{vehicle.vin} - - - {vehicle.group?.name || 'Unassigned'} - - - - - {displayStatus} - - - - v{vehicle.currentVersion} - - - - {new Date(vehicle.lastHeartbeat).toLocaleTimeString()} -
- No vehicles registered yet. -
+ const groupedVehicles = useMemo(() => { + const grouped: Record = { 'unassigned': [] }; + // Initialize groups + groups.forEach(g => { + grouped[g.id] = []; + }); + + // Distribute vehicles + vehicles.forEach(v => { + if (v.groupId && grouped[v.groupId]) { + grouped[v.groupId].push(v); + } else { + grouped['unassigned'].push(v); + } + }); + + return grouped; + }, [vehicles, groups]); + + const getGroupStats = (groupVehicles: Vehicle[]) => { + const online = groupVehicles.filter(v => getVehicleDisplayStatus(v) === 'Online').length; + const total = groupVehicles.length; + const versions = groupVehicles.reduce((acc, v) => { + acc[v.currentVersion] = (acc[v.currentVersion] || 0) + 1; + return acc; + }, {} as Record); + + const versionStr = Object.entries(versions) + .map(([v, count]) => `v${v} (${Math.round((count / total) * 100)}%)`) + .join(', '); + + return { online, total, versionStr }; + }; + + // Helper to get group details + const getGroupDetails = (key: string) => { + if (key === 'unassigned') return { name: 'Unassigned', description: 'Vehicles without a group' }; + const group = groups.find(g => g.id === Number(key)); + return group || { name: 'Unknown Group', description: '' }; + }; + + 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); + setCollapsedGroups(allCollapsed); + }; + + return ( +
+
+

+ + Fleet Overview +

+
+ + +
+ + {groupKeys.length === 0 && ( +
+ No groups or vehicles found. +
+ )} + + {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 ( +
+
toggleGroup(key)} + > +
+ + {isCollapsed ? : } + +
+

+ + {name} +

+ {description &&

{description}

} +
+
+
+ + {stats.online}/{stats.total} Online + + {stats.total > 0 && ( + + {stats.versionStr} + + )} +
+
+ + {!isCollapsed && ( +
+ {groupVs.length > 0 ? ( +
+ + + + + + + + + + + {groupVs.map((vehicle) => { + const displayStatus = getVehicleDisplayStatus(vehicle); + return ( + + + + + + + ) + })} + +
VINStatusVersionLast Heartbeat
{vehicle.vin} + + + {displayStatus} + + + + v{vehicle.currentVersion} + + + + {new Date(vehicle.lastHeartbeat).toLocaleTimeString()} +
+
+ ) : ( +
+ No vehicles in this group. +
+ )} +
+ )} +
+ ); + })}
); }