mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import {
|
|
ArrowRight,
|
|
Building2,
|
|
Clock,
|
|
Eye,
|
|
LayoutGrid,
|
|
List,
|
|
MapPin,
|
|
MoreHorizontal,
|
|
Pencil,
|
|
Plus,
|
|
Search,
|
|
Train,
|
|
Trash2,
|
|
Truck,
|
|
} from "lucide-react";
|
|
|
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
|
import NewShipmentPage from "./NewShipmentPage";
|
|
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
|
import {
|
|
shipments,
|
|
type Shipment,
|
|
type ShipmentMode,
|
|
type ShipmentStatus,
|
|
} from "./shipments.mock";
|
|
import {
|
|
Button,
|
|
Card,
|
|
CardHeader,
|
|
CardTitle,
|
|
CardDescription,
|
|
CardContent,
|
|
Input,
|
|
DropdownMenu,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
} from "@edr/ui-common";
|
|
|
|
type FilterValue = "All" | ShipmentStatus;
|
|
type ViewMode = "grid" | "table";
|
|
|
|
const FILTERS: FilterValue[] = ["All", "In Transit", "Delivered", "Delayed"];
|
|
|
|
export default function TrackingPage() {
|
|
const [filter, setFilter] = useState<FilterValue>("All");
|
|
const [query, setQuery] = useState("");
|
|
const [view, setView] = useState<ViewMode>("grid");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
return shipments.filter((s) => {
|
|
if (filter !== "All" && s.status !== filter) return false;
|
|
if (!q) return true;
|
|
return (
|
|
s.reference.toLowerCase().includes(q) ||
|
|
s.consignmentReference.toLowerCase().includes(q) ||
|
|
s.bookingReference.toLowerCase().includes(q) ||
|
|
s.customer.toLowerCase().includes(q) ||
|
|
s.originStation.toLowerCase().includes(q) ||
|
|
s.destinationStation.toLowerCase().includes(q) ||
|
|
s.currentLocation.toLowerCase().includes(q)
|
|
);
|
|
});
|
|
}, [filter, query]);
|
|
|
|
return (
|
|
<div className="min-h-screen p-6">
|
|
<div className="space-y-6">
|
|
<Breadcrumbs items={[{ label: "Tracking" }]} />
|
|
|
|
<Card className="p-6 flex-row justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
|
Shipment Tracking
|
|
</h1>
|
|
<p className="mt-1 text-sm text-secondary-foreground">
|
|
Real-time cargo and shipment monitoring.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
|
<div className="relative w-full sm:w-80">
|
|
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
|
<Input
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder="Search shipments..."
|
|
className="pl-8!"
|
|
/>
|
|
</div>
|
|
|
|
<NewShipmentPage>
|
|
<Button>
|
|
<Plus />
|
|
New Shipment
|
|
</Button>
|
|
</NewShipmentPage>
|
|
</div>
|
|
</Card>
|
|
|
|
<Card className="p-2">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<div className="flex flex-wrap gap-1">
|
|
{FILTERS.map((f) => {
|
|
const isActive = f === filter;
|
|
const count =
|
|
f === "All"
|
|
? shipments.length
|
|
: shipments.filter((s) => s.status === f).length;
|
|
return (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => setFilter(f)}
|
|
className={
|
|
isActive
|
|
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
|
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
|
|
}
|
|
>
|
|
{f}
|
|
<span
|
|
className={
|
|
isActive
|
|
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
|
|
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
|
|
}
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
|
|
<ViewToggleButton
|
|
active={view === "grid"}
|
|
onClick={() => setView("grid")}
|
|
label="Grid view"
|
|
>
|
|
<LayoutGrid />
|
|
<span className="hidden sm:inline">Grid</span>
|
|
</ViewToggleButton>
|
|
<ViewToggleButton
|
|
active={view === "table"}
|
|
onClick={() => setView("table")}
|
|
label="Table view"
|
|
>
|
|
<List />
|
|
<span className="hidden sm:inline">Table</span>
|
|
</ViewToggleButton>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
{filtered.length === 0 ? (
|
|
<Card className="p-12 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
No shipments match your filters.
|
|
</p>
|
|
</Card>
|
|
) : view === "grid" ? (
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
|
{filtered.map((shipment) => (
|
|
<ShipmentCard key={shipment.id} shipment={shipment} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<ShipmentTable shipments={filtered} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ViewToggleButton({
|
|
active,
|
|
onClick,
|
|
label,
|
|
children,
|
|
}: {
|
|
active: boolean;
|
|
onClick: () => void;
|
|
label: string;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={onClick}
|
|
aria-label={label}
|
|
aria-pressed={active}
|
|
className={
|
|
active
|
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
|
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
|
|
}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
|
return (
|
|
<Card className="p-5">
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex flex-col gap-0.5">
|
|
<span className="text-base font-bold text-slate-900">
|
|
{shipment.reference}
|
|
</span>
|
|
<span className="text-xs text-slate-500">
|
|
Consignment {shipment.consignmentReference}
|
|
</span>
|
|
</div>
|
|
<StatusBadge status={shipment.status} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between rounded-2xl bg-primary/5 p-3">
|
|
<div className="text-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
From
|
|
</p>
|
|
<p className="font-semibold text-slate-900">
|
|
{shipment.originStation}
|
|
</p>
|
|
</div>
|
|
<ArrowRight className="text-primary" />
|
|
<div className="text-right text-sm">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
To
|
|
</p>
|
|
<p className="font-semibold text-slate-900">
|
|
{shipment.destinationStation}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
|
<span>Progress</span>
|
|
<span className="font-medium text-slate-700">
|
|
{shipment.progress}%
|
|
</span>
|
|
</div>
|
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full bg-primary transition-all"
|
|
style={{ width: `${shipment.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-sm text-slate-700">
|
|
<div className="flex items-center gap-2">
|
|
<Building2 />
|
|
<span>{shipment.customer}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<ModeIcon mode={shipment.mode} />
|
|
<span className="capitalize">{shipment.mode}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<MapPin />
|
|
<span>{shipment.currentLocation}</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
|
<Clock />
|
|
<span>
|
|
Updated {shipment.lastUpdate} · ETA {shipment.eta}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 border-t pt-3" onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="sm">
|
|
<MoreHorizontal />
|
|
Actions
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem>
|
|
<Eye />
|
|
View
|
|
</DropdownMenuItem>
|
|
<NewShipmentPage
|
|
mode="edit"
|
|
shipment={{
|
|
bookingId: shipment.bookingId,
|
|
bookingReference: shipment.bookingReference,
|
|
originStation: shipment.originStation,
|
|
destinationStation: shipment.destinationStation,
|
|
mode: shipment.mode,
|
|
status: shipment.status,
|
|
currentLocation: shipment.currentLocation,
|
|
eta: shipment.eta,
|
|
}}
|
|
>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
<Pencil />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
</NewShipmentPage>
|
|
<DropdownMenuSeparator />
|
|
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
|
<Trash2 />
|
|
Remove
|
|
</DropdownMenuItem>
|
|
</DeleteShipmentDialog>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|
return (
|
|
<Card className="gap-0">
|
|
<CardHeader className="flex flex-row items-center justify-between border-b">
|
|
<div>
|
|
<CardTitle>Shipment List</CardTitle>
|
|
<CardDescription>All shipments and their current status.</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent className="px-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[1000px] whitespace-nowrap text-left">
|
|
<thead className="bg-muted text-sm text-muted-foreground">
|
|
<tr>
|
|
<th className="px-6 py-4 font-medium">Shipment</th>
|
|
<th className="px-6 py-4 font-medium">Booking</th>
|
|
<th className="px-6 py-4 font-medium">Route</th>
|
|
<th className="px-6 py-4 font-medium">Mode</th>
|
|
<th className="px-6 py-4 font-medium">Status</th>
|
|
<th className="px-6 py-4 font-medium">Progress</th>
|
|
<th className="px-6 py-4 font-medium">Current Location</th>
|
|
<th className="px-6 py-4 font-medium">ETA</th>
|
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody>
|
|
{rows.map((shipment) => (
|
|
<tr
|
|
key={shipment.id}
|
|
className="border-t transition hover:bg-muted/50"
|
|
>
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
|
|
<Truck />
|
|
</div>
|
|
<div>
|
|
<p className="font-medium text-slate-900">
|
|
{shipment.reference}
|
|
</p>
|
|
<p className="text-sm text-slate-500">
|
|
{shipment.customer}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-sm text-slate-700">
|
|
{shipment.bookingReference}
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-sm text-slate-700">
|
|
<div className="flex items-center gap-2">
|
|
<span>{shipment.originStation}</span>
|
|
<ArrowRight className="text-slate-400" />
|
|
<span>{shipment.destinationStation}</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-6 py-4">
|
|
<div className="flex items-center gap-2 text-sm capitalize text-slate-700">
|
|
<ModeIcon mode={shipment.mode} />
|
|
{shipment.mode}
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-6 py-4">
|
|
<StatusBadge status={shipment.status} />
|
|
</td>
|
|
|
|
<td className="px-6 py-4">
|
|
<div className="flex w-32 items-center gap-2">
|
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
|
<div
|
|
className="h-full rounded-full bg-primary"
|
|
style={{ width: `${shipment.progress}%` }}
|
|
/>
|
|
</div>
|
|
<span className="text-xs font-medium text-slate-600">
|
|
{shipment.progress}%
|
|
</span>
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-sm text-slate-700">
|
|
<div className="flex items-center gap-1.5">
|
|
<MapPin />
|
|
{shipment.currentLocation}
|
|
</div>
|
|
</td>
|
|
|
|
<td className="px-6 py-4 text-sm text-slate-700">
|
|
{shipment.eta}
|
|
</td>
|
|
|
|
<td className="px-6 py-4">
|
|
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" size="icon">
|
|
<MoreHorizontal />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem>
|
|
<Eye />
|
|
View
|
|
</DropdownMenuItem>
|
|
<NewShipmentPage
|
|
mode="edit"
|
|
shipment={{
|
|
bookingId: shipment.bookingId,
|
|
bookingReference: shipment.bookingReference,
|
|
originStation: shipment.originStation,
|
|
destinationStation: shipment.destinationStation,
|
|
mode: shipment.mode,
|
|
status: shipment.status,
|
|
currentLocation: shipment.currentLocation,
|
|
eta: shipment.eta,
|
|
}}
|
|
>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
|
|
<Pencil />
|
|
Edit
|
|
</DropdownMenuItem>
|
|
</NewShipmentPage>
|
|
<DropdownMenuSeparator />
|
|
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
|
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()} variant="destructive">
|
|
<Trash2 />
|
|
Remove
|
|
</DropdownMenuItem>
|
|
</DeleteShipmentDialog>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function ModeIcon({ mode }: { mode: ShipmentMode }) {
|
|
if (mode === "rail") return <Train />;
|
|
if (mode === "truck") return <Truck />;
|
|
return <Train />;
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: ShipmentStatus }) {
|
|
const styles: Record<ShipmentStatus, string> = {
|
|
"In Transit": "bg-indigo-100 text-indigo-700",
|
|
Delivered: "bg-emerald-100 text-emerald-700",
|
|
Delayed: "bg-red-100 text-red-700",
|
|
};
|
|
|
|
return (
|
|
<span
|
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
|
>
|
|
{status}
|
|
</span>
|
|
);
|
|
}
|