Files
edr-platform/apps/edr-freight-web/portal/src/pages/tracking/TrackingPage.tsx

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>
);
}