Added new Sorting of Fleet Overview

This commit is contained in:
Denis Urs Rudolph
2025-12-11 21:28:36 +01:00
parent 945df52f0c
commit fdbcec16ed
3 changed files with 201 additions and 68 deletions

View File

@@ -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}

View File

@@ -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

View File

@@ -1,46 +1,176 @@
'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) {
// 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<Record<string, boolean>>({});
const groupedVehicles = useMemo(() => {
const grouped: Record<number | 'unassigned', Vehicle[]> = { '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<string, number>);
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<string, boolean>);
setCollapsedGroups(allCollapsed);
};
return ( return (
<div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl"> <div className="bg-zinc-900/50 backdrop-blur-md border border-zinc-800 rounded-xl p-6 shadow-xl space-y-6">
<h2 className="text-xl font-semibold text-white mb-4 flex items-center gap-2"> <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" /> <Car className="w-5 h-5 text-indigo-400" />
Fleet Overview Fleet Overview
</h2> </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>
{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"> <div className="overflow-x-auto">
<table className="w-full text-left"> <table className="w-full text-left">
<thead> <thead>
<tr className="border-b border-zinc-800 text-zinc-400 text-sm"> <tr className="border-b border-zinc-800 text-zinc-400 text-sm">
<th className="pb-3 pl-2">VIN</th> <th className="pb-3 pl-2 w-1/4">VIN</th>
<th className="pb-3">Group</th> <th className="pb-3 w-1/4">Status</th>
<th className="pb-3">Status</th> <th className="pb-3 w-1/4">Version</th>
<th className="pb-3">Version</th> <th className="pb-3 w-1/4">Last Heartbeat</th>
<th className="pb-3">Last Heartbeat</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-800/50"> <tbody className="divide-y divide-zinc-800/50">
{vehicles.map((vehicle) => { {groupVs.map((vehicle) => {
// Calculate status: Offline if no heartbeat for 5s
const displayStatus = getVehicleDisplayStatus(vehicle); const displayStatus = getVehicleDisplayStatus(vehicle);
return ( return (
<tr key={vehicle.vin} className="text-zinc-300 hover:bg-zinc-800/30 transition-colors"> <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 pl-2 font-mono text-sm">{vehicle.vin}</td>
<td className="py-3">
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full bg-zinc-800 text-zinc-400">
<Layers className="w-3 h-3" />
{vehicle.group?.name || 'Unassigned'}
</span>
</td>
<td className="py-3"> <td className="py-3">
<span className={clsx( <span className={clsx(
"inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium", "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium",
@@ -64,16 +194,19 @@ export function VehicleList({ vehicles }: VehicleListProps) {
</tr> </tr>
) )
})} })}
{vehicles.length === 0 && (
<tr>
<td colSpan={5} className="text-center py-8 text-zinc-500">
No vehicles registered yet.
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </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>
); );
} }