add ui for major components

This commit is contained in:
yaschalew
2026-05-15 22:17:31 +03:00
parent 84f4f7edba
commit b5fe2c2e3c
46 changed files with 7702 additions and 257 deletions

View File

@@ -1 +1,42 @@
@import "tailwindcss";
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
* {
scrollbar-width: thin;
scrollbar-color: rgb(203 213 225 / 0.6) transparent;
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: rgb(203 213 225 / 0.7);
border-radius: 9999px;
}
*::-webkit-scrollbar-thumb:hover {
background-color: rgb(51 87 141 / 0.5);
}
*::-webkit-scrollbar-corner {
background: transparent;
}
.dark * {
scrollbar-color: rgb(71 85 105 / 0.6) transparent;
}
.dark *::-webkit-scrollbar-thumb {
background-color: rgb(71 85 105 / 0.6);
}
.dark *::-webkit-scrollbar-thumb:hover {
background-color: rgb(51 87 141 / 0.7);
}

View File

@@ -17,11 +17,14 @@
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui-common": "1.1.1",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -6,10 +6,20 @@ import {
Navigate,
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import {
LayoutDashboard,
Users,
CalendarCheck,
Package,
MapPin,
Train,
Receipt,
FileText,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import CreateBookingPage from "./pages/bookings/CreateBookingPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
import TrackingPage from "./pages/tracking/TrackingPage";
@@ -17,16 +27,20 @@ import BillingPage from "./pages/billing/BillingPage";
import TrainsPage from "./pages/trains/TrainsPage";
import DashboardPage from "./pages/dashboard/DashboardPage";
import { IamLoginPage } from "@tria-plc/iamui-common";
import CustomersPage from "./pages/customers/CustomersPage";
import CustomersPage from "./pages/customers/CustomersPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
const sidebarItems: SidebarItem[] = [
{ label: "Dashboard", href: "/" },
{ label: "Customers", href: "/Customers" },
{ label: "Bookings", href: "/bookings" },
{ label: "Consignments", href: "/consignments" },
{ label: "Tracking", href: "/tracking" },
{ label: "Trains", href: "/trains" },
{ label: "Billing", href: "/billing" },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
];
const App = () => {
@@ -48,18 +62,22 @@ const App = () => {
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/bookings/new" element={<CreateBookingPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -0,0 +1,56 @@
import { Fragment } from "react";
import { Link } from "react-router-dom";
import { ChevronRight, Home } from "lucide-react";
export interface BreadcrumbItem {
label: string;
href?: string;
}
export interface BreadcrumbsProps {
items: BreadcrumbItem[];
}
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
return (
<nav
aria-label="Breadcrumb"
className="flex items-center text-sm text-slate-500"
>
<Link
to="/"
aria-label="Home"
className="flex items-center transition hover:text-[#33578D]"
>
{/* <Home className="h-4 w-4" /> */}
Dashboard
</Link>
{items.map((item, i) => {
const isLast = i === items.length - 1;
return (
<Fragment key={`${item.label}-${i}`}>
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
{item.href && !isLast ? (
<Link
to={item.href}
className="transition hover:text-[#33578D]"
>
{item.label}
</Link>
) : (
<span
aria-current={isLast ? "page" : undefined}
className="font-medium text-slate-900"
>
{item.label}
</span>
)}
</Fragment>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot.Root : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,141 @@
import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
};

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"flex h-10 w-full min-w-0 rounded-md border border-slate-200 bg-white px-3 py-1 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
"hover:border-slate-300",
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import { Label as LabelPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className,
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"field-sizing-content flex min-h-16 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
"hover:border-slate-300",
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
className,
)}
{...props}
/>
);
}
export { Textarea };

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -1,12 +1,473 @@
const BillingPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Billing</h1>
<p className="text-sm text-gray-600">
Invoice list and payment status will live here. Wire up
@tanstack/react-query to <code>/billing/invoices</code> when the feature
is built out.
</p>
</div>
);
import { useMemo, useState } from "react";
import {
AlertCircle,
ChevronLeft,
ChevronRight,
Clock,
DollarSign,
Download,
Filter,
Pencil,
Plus,
Receipt,
Search,
Trash2,
} from "lucide-react";
export default BillingPage;
import Breadcrumbs from "@/components/Breadcrumbs";
import NewInvoicePage from "./NewInvoicePage";
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
import {
formatCurrency,
invoices,
type InvoiceStatus,
} from "./invoices.mock";
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type FilterValue = "All" | InvoiceStatus;
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
export default function BillingPage() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return invoices.filter((inv) => {
if (filter !== "All" && inv.status !== filter) return false;
if (!q) return true;
return (
inv.number.toLowerCase().includes(q) ||
inv.customer.toLowerCase().includes(q) ||
inv.bookingReference.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => filtered.slice(start, end),
[filtered, start, end],
);
const totalRevenue = invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const outstanding = invoices
.filter(
(inv) =>
(inv.status === "Sent" || inv.status === "Overdue") &&
inv.currency === "USD",
)
.reduce((sum, inv) => sum + inv.amount, 0);
const overdueCount = invoices.filter((inv) => inv.status === "Overdue").length;
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Billing" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Billing
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage invoices, payments, and financial records.
</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-3 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);
setPage(1);
}}
placeholder="Search invoices..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewInvoicePage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Invoice
</button>
</NewInvoicePage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Revenue (USD)"
value={formatCurrency(totalRevenue, "USD")}
icon={<DollarSign className="h-5 w-5" />}
tone="brand"
/>
<StatCard
title="Outstanding (USD)"
value={formatCurrency(outstanding, "USD")}
icon={<Clock className="h-5 w-5" />}
tone="brand"
/>
<StatCard
title="Overdue Invoices"
value={String(overdueCount)}
icon={<AlertCircle className="h-5 w-5" />}
tone="danger"
/>
</div>
{/* Filter tabs */}
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? invoices.length
: invoices.filter((inv) => inv.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPage(1);
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{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>
{/* Invoices Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Invoices
</h2>
<p className="text-sm text-slate-500">
Issued invoices and their payment status.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Invoice</th>
<th className="px-6 py-4 font-medium">Customer</th>
<th className="px-6 py-4 font-medium">Booking</th>
<th className="px-6 py-4 font-medium">Amount</th>
<th className="px-6 py-4 font-medium">Due Date</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
No invoices match your filters.
</td>
</tr>
) : (
paginated.map((invoice) => (
<tr
key={invoice.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10 text-[#33578D]">
<Receipt className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{invoice.number}
</p>
<p className="text-sm text-slate-500">
Issued {invoice.issueDate}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{invoice.customer}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{invoice.bookingReference}
</td>
<td className="px-6 py-4 text-sm font-medium text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{invoice.dueDate}
</td>
<td className="px-6 py-4">
<StatusBadge status={invoice.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<button
type="button"
aria-label="Download invoice"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Download className="h-4 w-4" />
</button>
<NewInvoicePage
mode="edit"
invoice={{
number: invoice.number,
customerId: invoice.customerId,
bookingReference: invoice.bookingReference,
amount: invoice.amount,
currency: invoice.currency,
status: invoice.status,
issueDate: invoice.issueDate,
dueDate: invoice.dueDate,
notes: invoice.notes,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewInvoicePage>
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteInvoiceDialog>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</div>
);
}
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label htmlFor="invoice-page-size" className="font-medium text-slate-700">
Rows per page
</label>
<select
id="invoice-page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
icon,
tone,
}: {
title: string;
value: string;
icon: React.ReactNode;
tone?: "brand" | "danger";
}) {
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#33578D]/10 text-[#33578D]";
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
</div>
<div
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${iconWrap}`}
>
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteInvoiceDialogProps {
invoiceNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteInvoiceDialog({
invoiceNumber,
onConfirm,
children,
}: DeleteInvoiceDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Void invoice?
</DialogTitle>
<DialogDescription>
This will void invoice{" "}
<span className="font-semibold text-slate-900">
{invoiceNumber}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Void Invoice
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,202 @@
import type { ReactNode } from "react";
import { Calendar, DollarSign, Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
import type { Currency, InvoiceStatus } from "./invoices.mock";
export interface InvoiceFormData {
number?: string;
customerId?: number;
bookingReference?: string;
amount?: number;
currency?: Currency;
status?: InvoiceStatus;
issueDate?: string;
dueDate?: string;
notes?: string;
}
export interface NewInvoicePageProps {
mode?: "create" | "edit";
invoice?: InvoiceFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
export default function NewInvoicePage({
mode = "create",
invoice,
children,
}: NewInvoicePageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Invoice" : "New Invoice";
const description = isEdit
? "Update invoice details."
: "Create a new invoice for a customer booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Invoice Number */}
<div className="space-y-2">
<Label>Invoice Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={invoice?.number ?? ""}
placeholder="e.g. INV-2026-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={invoice?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Sent</option>
<option>Paid</option>
<option>Overdue</option>
<option>Cancelled</option>
</select>
</div>
{/* Customer */}
<div className="space-y-2">
<Label>Customer *</Label>
<select
defaultValue={invoice?.customerId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select customer
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.company}
</option>
))}
</select>
</div>
{/* Booking */}
<div className="space-y-2">
<Label>Booking Reference</Label>
<select
defaultValue={invoice?.bookingReference ?? ""}
className={selectClass}
>
<option value="">No linked booking</option>
{bookings.map((b) => (
<option key={b.id} value={b.reference}>
{b.reference} {b.customer}
</option>
))}
</select>
</div>
{/* Amount */}
<div className="space-y-2">
<Label>Amount *</Label>
<div className="relative">
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.01"
defaultValue={invoice?.amount ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Currency */}
<div className="space-y-2">
<Label>Currency</Label>
<select
defaultValue={invoice?.currency ?? "USD"}
className={selectClass}
>
<option>USD</option>
<option>ETB</option>
<option>DJF</option>
</select>
</div>
{/* Issue Date */}
<div className="space-y-2">
<Label>Issue Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.issueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Due Date */}
<div className="space-y-2">
<Label>Due Date *</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={invoice?.dueDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={invoice?.notes ?? ""}
placeholder="Payment terms, references, etc."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,88 @@
import { customers } from "../customers/customers.mock";
import { bookings } from "../bookings/bookings.mock";
export type InvoiceStatus =
| "Draft"
| "Sent"
| "Paid"
| "Overdue"
| "Cancelled";
export type Currency = "USD" | "ETB" | "DJF";
export interface Invoice {
id: number;
number: string;
customerId: number;
customer: string;
bookingReference: string;
amount: number;
currency: Currency;
status: InvoiceStatus;
issueDate: string;
dueDate: string;
paidDate: string | null;
notes: string;
}
const statuses: InvoiceStatus[] = [
"Draft",
"Sent",
"Paid",
"Overdue",
"Cancelled",
];
const currencies: Currency[] = ["USD", "ETB", "DJF"];
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
const issue = new Date(2026, 3, 1 + (i % 28));
const due = new Date(issue);
due.setDate(due.getDate() + 30);
const status = statuses[i % statuses.length] as InvoiceStatus;
const currency = currencies[i % currencies.length] as Currency;
const baseAmount = 5000 + (i * 1234) % 25000;
return {
id,
number: `INV-2026-${String(id).padStart(4, "0")}`,
customerId: customer.id,
customer: customer.company,
bookingReference: booking.reference,
amount: Math.round(baseAmount * 100) / 100,
currency,
status,
issueDate: issue.toISOString().slice(0, 10),
dueDate: due.toISOString().slice(0, 10),
paidDate:
status === "Paid"
? new Date(due.getTime() - 86400000 * (i % 7))
.toISOString()
.slice(0, 10)
: null,
notes:
i % 3 === 0
? "Net 30 payment terms."
: i % 3 === 1
? "Bank transfer preferred."
: "Payment due upon receipt.",
};
});
export function getInvoiceById(id: number | string): Invoice | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return invoices.find((inv) => inv.id === numericId);
}
export function formatCurrency(amount: number, currency: Currency): string {
const symbols: Record<Currency, string> = {
USD: "$",
ETB: "Br",
DJF: "DJF",
};
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`;
}

View File

@@ -1,34 +1,326 @@
import { useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
ArrowRight,
Calendar,
Flag,
MapPin,
Package,
StickyNote,
Trash2,
Train,
User,
Weight,
} from "lucide-react";
import { useBooking } from "../../hooks/useBookings";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "./NewBookingPage";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getBookingById, type BookingStatus } from "./bookings.mock";
const BookingDetailPage = () => {
export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>();
const { data: booking, isLoading } = useBooking(id ?? "");
const navigate = useNavigate();
const booking = id ? getBookingById(id) : undefined;
if (isLoading) return <div className="text-sm text-gray-500">Loading</div>;
if (!booking)
return <div className="text-sm text-red-600">Booking not found.</div>;
if (!booking) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<h1 className="text-2xl font-bold text-slate-900">
Booking not found
</h1>
<p className="mt-2 text-sm text-slate-500">
The booking you're looking for doesn't exist or has been removed.
</p>
<Link
to="/bookings"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Bookings
</Link>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">
Booking {booking.reference}
</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Status</dt>
<dd className="text-gray-900">{booking.status}</dd>
<dt className="text-gray-500">Customer ID</dt>
<dd className="text-gray-900">{booking.customerId}</dd>
<dt className="text-gray-500">Scheduled</dt>
<dd className="text-gray-900">
{new Date(booking.scheduledDate).toLocaleString()}
</dd>
<dt className="text-gray-500">Total amount</dt>
<dd className="text-gray-900">{booking.totalAmount.toFixed(2)}</dd>
</dl>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Bookings", href: "/bookings" },
{ label: booking.reference },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
<Package className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{booking.reference}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span>{booking.customer}</span>
<span className="text-slate-300"></span>
<span>{booking.requestedDate}</span>
<span className="text-slate-300"></span>
<StatusBadge status={booking.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<NewBookingPage
mode="edit"
booking={{
customerId: booking.customerId,
cargoType: booking.cargoType,
originStation: booking.originStation,
destinationStation: booking.destinationStation,
transportMode: booking.transportMode,
legs: booking.legs,
containerType: booking.containerType,
containerCount: booking.containerCount,
weightTons: booking.weightTons,
requestedDate: booking.requestedDate,
priority: booking.priority,
cargoDescription: booking.cargoDescription,
specialInstructions: booking.specialInstructions,
}}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
Edit Booking
</button>
</NewBookingPage>
<DeleteBookingDialog
bookingReference={booking.reference}
onConfirm={() => navigate("/bookings")}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Cancel
</button>
</DeleteBookingDialog>
</div>
</div>
</div>
{/* Route banner */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint
label="Origin"
station={booking.originStation}
icon={<MapPin className="h-5 w-5" />}
/>
<div className="flex items-center gap-2 text-[#33578D]">
<Train className="h-5 w-5" />
<ArrowRight className="h-5 w-5" />
</div>
<RouteEndpoint
label="Destination"
station={booking.destinationStation}
icon={<MapPin className="h-5 w-5" />}
/>
</div>
</div>
{/* Transport Legs (multimodal only) */}
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center gap-2">
<Train className="h-5 w-5 text-[#33578D]" />
<h2 className="text-lg font-semibold text-slate-900">
Transport Legs
</h2>
<span className="rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]">
{booking.legs.length} legs
</span>
</div>
<div className="space-y-3">
{booking.legs.map((leg, i) => (
<div
key={i}
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4 md:flex-row md:items-center md:justify-between"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
{i + 1}
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
Leg {i + 1} · {leg.mode}
</p>
<p className="font-semibold text-slate-900">
{leg.from || "—"}
<ArrowRight className="mx-2 inline h-4 w-4 text-[#33578D]" />
{leg.to || "—"}
</p>
</div>
</div>
</div>
))}
</div>
</div>
) : null}
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Customer & Cargo">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Customer"
value={booking.customer}
/>
<DetailRow
icon={<Package className="h-4 w-4" />}
label="Cargo Type"
value={booking.cargoType}
/>
<DetailRow
icon={<Package className="h-4 w-4" />}
label="Container"
value={`${booking.containerCount} × ${booking.containerType}`}
/>
<DetailRow
icon={<Weight className="h-4 w-4" />}
label="Weight"
value={`${booking.weightTons} tons`}
/>
</DetailCard>
<DetailCard title="Schedule & Mode">
<DetailRow
icon={<Train className="h-4 w-4" />}
label="Transport Mode"
value={booking.transportMode}
/>
<DetailRow
icon={<Calendar className="h-4 w-4" />}
label="Requested Date"
value={booking.requestedDate}
/>
<DetailRow
icon={<Flag className="h-4 w-4" />}
label="Priority"
value={booking.priority}
/>
</DetailCard>
<DetailCard title="Cargo Description">
<div className="flex items-start gap-3 text-sm text-slate-700">
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
<p className="leading-relaxed">{booking.cargoDescription}</p>
</div>
</DetailCard>
<DetailCard title="Special Instructions">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
<p className="leading-relaxed">{booking.specialInstructions}</p>
</div>
</DetailCard>
</div>
</div>
</div>
);
};
}
export default BookingDetailPage;
function RouteEndpoint({
label,
station,
icon,
}: {
label: string;
station: string;
icon: React.ReactNode;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="text-lg font-semibold text-slate-900">{station}</p>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#33578D]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "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>
);
}

View File

@@ -1,28 +1,380 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Button } from "@edr/ui-common";
import {
ArrowRight,
ChevronLeft,
ChevronRight,
Clock,
Eye,
Filter,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import BookingTable from "../../components/bookings/BookingTable";
import { useBookings } from "../../hooks/useBookings";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "./NewBookingPage";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { bookings, type BookingStatus } from "./bookings.mock";
const BookingsPage = () => {
const { data, isLoading } = useBookings();
const items = data?.items ?? [];
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
export default function BookingsPage() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const total = bookings.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => bookings.slice(start, end),
[start, end],
);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900">Bookings</h1>
<Link to="/bookings/new">
<Button>New booking</Button>
</Link>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Bookings" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Bookings
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage and monitor your freight bookings.
</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-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
placeholder="Search bookings..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewBookingPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Booking
</button>
</NewBookingPage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Bookings"
value={String(total)}
icon={<Package className="h-5 w-5" />}
/>
<StatCard
title="In Transit"
value={String(
bookings.filter((b) => b.status === "In Transit").length,
)}
icon={<Truck className="h-5 w-5" />}
/>
<StatCard
title="Pending"
value={String(
bookings.filter((b) => b.status === "Pending").length,
)}
icon={<Clock className="h-5 w-5" />}
/>
</div>
{/* Booking Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Booking List
</h2>
<p className="text-sm text-slate-500">
Recent freight bookings and their status.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Reference</th>
<th className="px-6 py-4 font-medium">Customer</th>
<th className="px-6 py-4 font-medium">Route</th>
<th className="px-6 py-4 font-medium">Cargo</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10 text-[#33578D]">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{booking.reference}
</p>
<p className="text-sm text-slate-500">
{booking.requestedDate}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{booking.customer}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-2">
<span>{booking.originStation}</span>
<ArrowRight className="h-3.5 w-3.5 text-slate-400" />
<span>{booking.destinationStation}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div>
<p>{booking.cargoType}</p>
<p className="text-xs text-slate-500">
{booking.containerCount} × {booking.containerType} ·{" "}
{booking.weightTons}t
</p>
</div>
</td>
<td className="px-6 py-4">
<StatusBadge status={booking.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Link
to={`/bookings/${booking.id}`}
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</Link>
<NewBookingPage
mode="edit"
booking={{
customerId: booking.customerId,
cargoType: booking.cargoType,
originStation: booking.originStation,
destinationStation: booking.destinationStation,
transportMode: booking.transportMode,
legs: booking.legs,
containerType: booking.containerType,
containerCount: booking.containerCount,
weightTons: booking.weightTons,
requestedDate: booking.requestedDate,
priority: booking.priority,
cargoDescription: booking.cargoDescription,
specialInstructions: booking.specialInstructions,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewBookingPage>
<DeleteBookingDialog
bookingReference={booking.reference}
>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteBookingDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
{isLoading ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<BookingTable bookings={items} />
)}
</div>
);
};
}
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label htmlFor="booking-page-size" className="font-medium text-slate-700">
Rows per page
</label>
<select
id="booking-page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "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>
);
}
export default BookingsPage;

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteBookingDialogProps {
bookingReference: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteBookingDialog({
bookingReference,
onConfirm,
children,
}: DeleteBookingDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Cancel booking?
</DialogTitle>
<DialogDescription>
This will permanently cancel booking{" "}
<span className="font-semibold text-slate-900">
{bookingReference}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Keep booking</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Cancel booking
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,366 @@
import { useState, type ReactNode } from "react";
import { ArrowRight, Calendar, MapPin, Package, Plus, Trash2, Weight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { customers } from "../customers/customers.mock";
import type {
CargoType,
ContainerType,
LegMode,
Priority,
TransportLeg,
TransportMode,
} from "./bookings.mock";
export interface BookingFormData {
customerId?: number;
cargoType?: CargoType;
originStation?: string;
destinationStation?: string;
transportMode?: TransportMode;
legs?: TransportLeg[];
containerType?: ContainerType;
containerCount?: number;
weightTons?: number;
requestedDate?: string;
priority?: Priority;
cargoDescription?: string;
specialInstructions?: string;
}
export interface NewBookingPageProps {
mode?: "create" | "edit";
booking?: BookingFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
const emptyLeg: TransportLeg = { mode: "Rail", from: "", to: "" };
export default function NewBookingPage({
mode = "create",
booking,
children,
}: NewBookingPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Freight Booking" : "New Freight Booking";
const description = isEdit
? "Update an existing freight booking."
: "Create a freight booking with cargo and route details.";
const submitLabel = isEdit ? "Save Changes" : "Submit Booking";
const [transportMode, setTransportMode] = useState<TransportMode>(
booking?.transportMode ?? "Rail",
);
const [legs, setLegs] = useState<TransportLeg[]>(
booking?.legs && booking.legs.length > 0
? booking.legs
: [{ ...emptyLeg }],
);
const addLeg = () =>
setLegs((prev) => [...prev, { ...emptyLeg }]);
const removeLeg = (index: number) =>
setLegs((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
const updateLeg = (index: number, patch: Partial<TransportLeg>) =>
setLegs((prev) =>
prev.map((leg, i) => (i === index ? { ...leg, ...patch } : leg)),
);
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Booking"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Customer */}
<div className="space-y-2">
<Label>Customer *</Label>
<select
defaultValue={booking?.customerId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select customer
</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{c.company}
</option>
))}
</select>
</div>
{/* Cargo Type */}
<div className="space-y-2">
<Label>Cargo Type *</Label>
<select
defaultValue={booking?.cargoType ?? "Containerized"}
className={selectClass}
>
<option>Containerized</option>
<option>Bulk</option>
<option>Liquid</option>
<option>Refrigerated</option>
<option>Hazardous</option>
</select>
</div>
{/* Origin Station */}
<div className="space-y-2">
<Label>Origin Station *</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={booking?.originStation ?? ""}
placeholder="e.g. Addis Ababa"
className="pl-10"
/>
</div>
</div>
{/* Destination Station */}
<div className="space-y-2">
<Label>Destination Station *</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={booking?.destinationStation ?? ""}
placeholder="e.g. Djibouti"
className="pl-10"
/>
</div>
</div>
{/* Transport Mode (controlled) */}
<div className="space-y-2">
<Label>Transport Mode</Label>
<select
value={transportMode}
onChange={(e) =>
setTransportMode(e.target.value as TransportMode)
}
className={selectClass}
>
<option>Rail</option>
<option>Truck</option>
<option>Multimodal</option>
</select>
</div>
{/* Container Type */}
<div className="space-y-2">
<Label>Container Type</Label>
<select
defaultValue={booking?.containerType ?? "20FT"}
className={selectClass}
>
<option>20FT</option>
<option>40FT</option>
<option>40HC</option>
<option>Reefer</option>
</select>
</div>
{/* Multimodal Transport Legs */}
{transportMode === "Multimodal" ? (
<div className="md:col-span-2">
<div className="rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div>
<p className="text-sm font-semibold text-slate-900">
Transport Legs
</p>
<p className="text-xs text-slate-500">
Define each segment of the multimodal journey.
</p>
</div>
<button
type="button"
onClick={addLeg}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#33578D] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Leg
</button>
</div>
<div className="space-y-3">
{legs.map((leg, i) => (
<div
key={i}
className="rounded-xl border border-slate-200 bg-white p-3"
>
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-wide text-[#33578D]">
Leg {i + 1}
</span>
{legs.length > 1 ? (
<button
type="button"
onClick={() => removeLeg(i)}
aria-label={`Remove leg ${i + 1}`}
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-3">
<div className="space-y-1.5">
<Label className="text-xs">Mode</Label>
<select
value={leg.mode}
onChange={(e) =>
updateLeg(i, {
mode: e.target.value as LegMode,
})
}
className={selectClass}
>
<option>Rail</option>
<option>Truck</option>
</select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">From</Label>
<Input
value={leg.from}
onChange={(e) =>
updateLeg(i, { from: e.target.value })
}
placeholder="Start station"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">To</Label>
<div className="relative">
<ArrowRight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
value={leg.to}
onChange={(e) =>
updateLeg(i, { to: e.target.value })
}
placeholder="End station"
className="pl-10"
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
) : null}
{/* Container Count */}
<div className="space-y-2">
<Label>Container Count</Label>
<div className="relative">
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={1}
defaultValue={booking?.containerCount ?? 1}
className="pl-10"
/>
</div>
</div>
{/* Weight */}
<div className="space-y-2">
<Label>Weight (Tons)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={booking?.weightTons ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Requested Date */}
<div className="space-y-2">
<Label>Requested Date</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={booking?.requestedDate ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Priority */}
<div className="space-y-2">
<Label>Priority</Label>
<select
defaultValue={booking?.priority ?? "Normal"}
className={selectClass}
>
<option>Normal</option>
<option>High</option>
<option>Urgent</option>
</select>
</div>
{/* Cargo Description */}
<div className="space-y-2 md:col-span-2">
<Label>Cargo Description</Label>
<Textarea
defaultValue={booking?.cargoDescription ?? ""}
placeholder="Describe the cargo..."
/>
</div>
{/* Special Instructions */}
<div className="space-y-2 md:col-span-2">
<Label>Special Instructions</Label>
<Textarea
defaultValue={booking?.specialInstructions ?? ""}
placeholder="Any special handling instructions..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,131 @@
import { customers } from "../customers/customers.mock";
export type BookingStatus =
| "Pending"
| "Confirmed"
| "In Transit"
| "Delivered"
| "Cancelled";
export type CargoType =
| "Containerized"
| "Bulk"
| "Liquid"
| "Refrigerated"
| "Hazardous";
export type TransportMode = "Rail" | "Truck" | "Multimodal";
export type LegMode = "Rail" | "Truck";
export type ContainerType = "20FT" | "40FT" | "40HC" | "Reefer";
export type Priority = "Normal" | "High" | "Urgent";
export interface TransportLeg {
mode: LegMode;
from: string;
to: string;
}
export interface Booking {
id: number;
reference: string;
customerId: number;
customer: string;
cargoType: CargoType;
originStation: string;
destinationStation: string;
transportMode: TransportMode;
legs?: TransportLeg[];
containerType: ContainerType;
containerCount: number;
weightTons: number;
requestedDate: string;
priority: Priority;
cargoDescription: string;
specialInstructions: string;
status: BookingStatus;
}
const stations = [
"Addis Ababa",
"Adama",
"Mojo",
"Awash",
"Mieso",
"Dire Dawa",
"Aysha",
"Ali Sabieh",
"Holhol",
"Djibouti City",
];
const cargoTypes: CargoType[] = [
"Containerized",
"Bulk",
"Liquid",
"Refrigerated",
"Hazardous",
];
const modes: TransportMode[] = ["Rail", "Truck", "Multimodal"];
const containerTypes: ContainerType[] = ["20FT", "40FT", "40HC", "Reefer"];
const statuses: BookingStatus[] = [
"Pending",
"Confirmed",
"In Transit",
"Delivered",
"Cancelled",
];
const priorities: Priority[] = ["Normal", "High", "Urgent"];
function pickStation(i: number, offset: number) {
return stations[(i + offset) % stations.length] as string;
}
export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
const customer = customers[i % customers.length] as (typeof customers)[number];
const id = i + 1;
const requested = new Date(2026, 4, 1 + (i % 28));
const transportMode = modes[i % modes.length] as TransportMode;
const originStation = pickStation(i, 0);
const destinationStation = pickStation(i, 5);
const midStation = pickStation(i, 3);
const legs: TransportLeg[] | undefined =
transportMode === "Multimodal"
? [
{ mode: "Rail", from: originStation, to: midStation },
{ mode: "Truck", from: midStation, to: destinationStation },
]
: undefined;
return {
id,
reference: `BK-${String(2026000 + id).slice(-6)}`,
customerId: customer.id,
customer: customer.company,
cargoType: cargoTypes[i % cargoTypes.length] as CargoType,
originStation,
destinationStation,
transportMode,
legs,
containerType: containerTypes[i % containerTypes.length] as ContainerType,
containerCount: (i % 5) + 1,
weightTons: 10 + ((i * 3) % 40),
requestedDate: requested.toISOString().slice(0, 10),
priority: priorities[i % priorities.length] as Priority,
cargoDescription:
i % 3 === 0
? "Coffee beans, sealed sacks"
: i % 3 === 1
? "Construction materials"
: "General merchandise",
specialInstructions:
i % 2 === 0 ? "Handle with care" : "Standard handling required",
status: statuses[i % statuses.length] as BookingStatus,
};
});
export function getBookingById(id: number | string): Booking | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return bookings.find((b) => b.id === numericId);
}

View File

@@ -1,34 +1,295 @@
import { useParams } from "react-router-dom";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Building2,
Calendar,
Hash,
MapPin,
Package,
Ruler,
StickyNote,
Trash2,
Weight,
} from "lucide-react";
import { useConsignment } from "../../hooks/useConsignments";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import {
getConsignmentById,
type ConsignmentStatus,
} from "./consignments.mock";
const ConsignmentDetailPage = () => {
export default function ConsignmentDetailPage() {
const { id } = useParams<{ id: string }>();
const { data, isLoading } = useConsignment(id ?? "");
const navigate = useNavigate();
const consignment = id ? getConsignmentById(id) : undefined;
if (isLoading) return <div className="text-sm text-gray-500">Loading</div>;
if (!data)
return <div className="text-sm text-red-600">Consignment not found.</div>;
if (!consignment) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<h1 className="text-2xl font-bold text-slate-900">
Consignment not found
</h1>
<p className="mt-2 text-sm text-slate-500">
The consignment you're looking for doesn't exist or has been
removed.
</p>
<Link
to="/consignments"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Consignments
</Link>
</div>
</div>
</div>
);
}
return (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">
Consignment {data.trackingNumber}
</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Status</dt>
<dd className="text-gray-900">{data.status}</dd>
<dt className="text-gray-500">Cargo</dt>
<dd className="text-gray-900">{data.cargoType}</dd>
<dt className="text-gray-500">Origin</dt>
<dd className="text-gray-900">{data.originStation}</dd>
<dt className="text-gray-500">Destination</dt>
<dd className="text-gray-900">{data.destinationStation}</dd>
<dt className="text-gray-500">Weight</dt>
<dd className="text-gray-900">{data.weightKg.toFixed(2)} kg</dd>
</dl>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: consignment.trackingNumber },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
<Package className="h-8 w-8" />
</div>
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900">
{consignment.trackingNumber}
{consignment.hazardous ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-700">
<AlertTriangle className="h-3.5 w-3.5" />
Hazardous
</span>
) : null}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span>{consignment.customer}</span>
<span className="text-slate-300"></span>
<span>Booking {consignment.bookingReference}</span>
<span className="text-slate-300"></span>
<StatusBadge status={consignment.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
Edit Consignment
</button>
</NewConsignmentPage>
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
onConfirm={() => navigate("/consignments")}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Remove
</button>
</DeleteConsignmentDialog>
</div>
</div>
</div>
{/* Route banner */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint
label="Origin"
station={consignment.originStation}
/>
<ArrowRight className="h-5 w-5 text-[#33578D]" />
<RouteEndpoint
label="Destination"
station={consignment.destinationStation}
/>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Cargo">
<DetailRow
icon={<Package className="h-4 w-4" />}
label="Cargo Type"
value={consignment.cargoType}
/>
<DetailRow
icon={<Hash className="h-4 w-4" />}
label="Pieces"
value={String(consignment.pieces)}
/>
<DetailRow
icon={<Weight className="h-4 w-4" />}
label="Weight"
value={`${consignment.weightKg.toLocaleString()} kg`}
/>
<DetailRow
icon={<Ruler className="h-4 w-4" />}
label="Volume"
value={`${consignment.volumeM3}`}
/>
</DetailCard>
<DetailCard title="References & Schedule">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Customer"
value={consignment.customer}
/>
<DetailRow
icon={<Hash className="h-4 w-4" />}
label="Booking"
value={consignment.bookingReference}
/>
<DetailRow
icon={<Calendar className="h-4 w-4" />}
label="Created"
value={consignment.createdAt}
/>
<DetailRow
icon={<Calendar className="h-4 w-4" />}
label="Estimated Delivery"
value={consignment.estimatedDelivery}
/>
</DetailCard>
<DetailCard title="Description">
<div className="flex items-start gap-3 text-sm text-slate-700">
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
<p className="leading-relaxed">{consignment.description}</p>
</div>
</DetailCard>
<DetailCard title="Special Handling">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
<p className="leading-relaxed">{consignment.specialHandling}</p>
</div>
</DetailCard>
</div>
</div>
</div>
);
};
}
export default ConsignmentDetailPage;
function RouteEndpoint({
label,
station,
}: {
label: string;
station: string;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
<MapPin className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="text-lg font-semibold text-slate-900">{station}</p>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#33578D]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "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>
);
}

View File

@@ -1,20 +1,503 @@
import ConsignmentTable from "../../components/consignments/ConsignmentTable";
import { useConsignments } from "../../hooks/useConsignments";
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
ChevronLeft,
ChevronRight,
Eye,
Filter,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
const ConsignmentsPage = () => {
const { data, isLoading } = useConsignments();
const items = data?.items ?? [];
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import {
consignments,
type ConsignmentStatus,
} from "./consignments.mock";
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type FilterValue = "All" | ConsignmentStatus;
const FILTERS: FilterValue[] = [
"All",
"Pending",
"In Warehouse",
"In Transit",
"Delivered",
"Returned",
];
export default function ConsignmentsPage() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return consignments.filter((c) => {
if (filter !== "All" && c.status !== filter) return false;
if (!q) return true;
return (
c.trackingNumber.toLowerCase().includes(q) ||
c.bookingReference.toLowerCase().includes(q) ||
c.customer.toLowerCase().includes(q) ||
c.originStation.toLowerCase().includes(q) ||
c.destinationStation.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => filtered.slice(start, end),
[filtered, start, end],
);
const inTransitCount = consignments.filter(
(c) => c.status === "In Transit",
).length;
const deliveredCount = consignments.filter(
(c) => c.status === "Delivered",
).length;
const hazmatCount = consignments.filter((c) => c.hazardous).length;
return (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Consignments</h1>
{isLoading ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<ConsignmentTable consignments={items} />
)}
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Consignments" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Consignments
</h1>
<p className="mt-1 text-sm text-slate-500">
Track cargo units and their handling status.
</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-3 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);
setPage(1);
}}
placeholder="Search consignments..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewConsignmentPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Consignment
</button>
</NewConsignmentPage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Total Consignments"
value={String(consignments.length)}
icon={<Package className="h-5 w-5" />}
/>
<StatCard
title="In Transit"
value={String(inTransitCount)}
icon={<Truck className="h-5 w-5" />}
/>
<StatCard
title="Delivered"
value={String(deliveredCount)}
icon={<CheckCircle2 className="h-5 w-5" />}
/>
<StatCard
title="Hazardous"
value={String(hazmatCount)}
icon={<AlertTriangle className="h-5 w-5" />}
tone="danger"
/>
</div>
{/* Filter tabs */}
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? consignments.length
: consignments.filter((c) => c.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPage(1);
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{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>
{/* Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Consignment List
</h2>
<p className="text-sm text-slate-500">
Cargo units and their current handling status.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Tracking #</th>
<th className="px-6 py-4 font-medium">Booking</th>
<th className="px-6 py-4 font-medium">Customer</th>
<th className="px-6 py-4 font-medium">Route</th>
<th className="px-6 py-4 font-medium">Cargo</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
No consignments match your filters.
</td>
</tr>
) : (
paginated.map((consignment) => (
<tr
key={consignment.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10 text-[#33578D]">
<Package className="h-5 w-5" />
</div>
<div>
<p className="flex items-center gap-2 font-medium text-slate-900">
{consignment.trackingNumber}
{consignment.hazardous ? (
<span
title="Hazardous"
className="inline-flex items-center gap-0.5 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700"
>
<AlertTriangle className="h-3 w-3" />
DG
</span>
) : null}
</p>
<p className="text-sm text-slate-500">
{consignment.createdAt}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{consignment.bookingReference}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{consignment.customer}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-2">
<span>{consignment.originStation}</span>
<ArrowRight className="h-3.5 w-3.5 text-slate-400" />
<span>{consignment.destinationStation}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div>
<p>{consignment.cargoType}</p>
<p className="text-xs text-slate-500">
{consignment.pieces} pcs ·{" "}
{consignment.weightKg.toLocaleString()} kg ·{" "}
{consignment.volumeM3} m³
</p>
</div>
</td>
<td className="px-6 py-4">
<StatusBadge status={consignment.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Link
to={`/consignments/${consignment.id}`}
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</Link>
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewConsignmentPage>
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteConsignmentDialog>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</div>
);
};
}
export default ConsignmentsPage;
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label
htmlFor="consignment-page-size"
className="font-medium text-slate-700"
>
Rows per page
</label>
<select
id="consignment-page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
icon,
tone,
}: {
title: string;
value: string;
icon: React.ReactNode;
tone?: "brand" | "danger";
}) {
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#33578D]/10 text-[#33578D]";
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${iconWrap}`}
>
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "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>
);
}

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteConsignmentDialogProps {
trackingNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteConsignmentDialog({
trackingNumber,
onConfirm,
children,
}: DeleteConsignmentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Remove consignment?
</DialogTitle>
<DialogDescription>
This will permanently remove consignment{" "}
<span className="font-semibold text-slate-900">
{trackingNumber}
</span>
. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Remove
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,235 @@
import type { ReactNode } from "react";
import { Calendar, Hash, Package, Ruler, Weight } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { bookings } from "../bookings/bookings.mock";
import type {
ConsignmentCargo,
ConsignmentStatus,
} from "./consignments.mock";
export interface ConsignmentFormData {
trackingNumber?: string;
bookingId?: number;
bookingReference?: string;
cargoType?: ConsignmentCargo;
description?: string;
weightKg?: number;
volumeM3?: number;
pieces?: number;
hazardous?: boolean;
specialHandling?: string;
status?: ConsignmentStatus;
estimatedDelivery?: string;
}
export interface NewConsignmentPageProps {
mode?: "create" | "edit";
consignment?: ConsignmentFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
export default function NewConsignmentPage({
mode = "create",
consignment,
children,
}: NewConsignmentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Consignment" : "New Consignment";
const description = isEdit
? "Update consignment details."
: "Register a new consignment under a freight booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Consignment";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? (
<Button>{isEdit ? "Edit" : "New Consignment"}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Tracking Number */}
<div className="space-y-2">
<Label>Tracking Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={consignment?.trackingNumber ?? ""}
placeholder="e.g. CGM-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={consignment?.status ?? "Pending"}
className={selectClass}
>
<option>Pending</option>
<option>In Warehouse</option>
<option>In Transit</option>
<option>Delivered</option>
<option>Returned</option>
</select>
</div>
{/* Booking */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<select
defaultValue={consignment?.bookingId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
</option>
))}
</select>
</div>
{/* Cargo Type */}
<div className="space-y-2">
<Label>Cargo Type *</Label>
<select
defaultValue={consignment?.cargoType ?? "Containerized"}
className={selectClass}
>
<option>Containerized</option>
<option>Bulk</option>
<option>Liquid</option>
<option>Refrigerated</option>
<option>Hazardous</option>
<option>General</option>
</select>
</div>
{/* Pieces */}
<div className="space-y-2">
<Label>Pieces</Label>
<div className="relative">
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={1}
defaultValue={consignment?.pieces ?? 1}
className="pl-10"
/>
</div>
</div>
{/* Weight */}
<div className="space-y-2">
<Label>Weight (kg)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.weightKg ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Volume */}
<div className="space-y-2">
<Label>Volume (m³)</Label>
<div className="relative">
<Ruler className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.volumeM3 ?? 0}
className="pl-10"
/>
</div>
</div>
{/* ETA */}
<div className="space-y-2">
<Label>Estimated Delivery</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={consignment?.estimatedDelivery ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Hazardous */}
<div className="space-y-2">
<Label>Hazardous Goods</Label>
<label className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700">
<input
type="checkbox"
defaultChecked={consignment?.hazardous ?? false}
className="h-4 w-4 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
/>
<span>Mark as hazardous (DG)</span>
</label>
</div>
{/* Description */}
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
defaultValue={consignment?.description ?? ""}
placeholder="Describe the consignment contents..."
/>
</div>
{/* Special Handling */}
<div className="space-y-2 md:col-span-2">
<Label>Special Handling</Label>
<Textarea
defaultValue={consignment?.specialHandling ?? ""}
placeholder="Any handling instructions..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,112 @@
import { bookings } from "../bookings/bookings.mock";
export type ConsignmentStatus =
| "Pending"
| "In Warehouse"
| "In Transit"
| "Delivered"
| "Returned";
export type ConsignmentCargo =
| "Containerized"
| "Bulk"
| "Liquid"
| "Refrigerated"
| "Hazardous"
| "General";
export interface Consignment {
id: number;
trackingNumber: string;
bookingId: number;
bookingReference: string;
customer: string;
originStation: string;
destinationStation: string;
cargoType: ConsignmentCargo;
description: string;
weightKg: number;
volumeM3: number;
pieces: number;
hazardous: boolean;
specialHandling: string;
status: ConsignmentStatus;
createdAt: string;
estimatedDelivery: string;
}
const cargoTypes: ConsignmentCargo[] = [
"Containerized",
"Bulk",
"Liquid",
"Refrigerated",
"Hazardous",
"General",
];
const statuses: ConsignmentStatus[] = [
"Pending",
"In Warehouse",
"In Transit",
"Delivered",
"Returned",
];
const descriptions = [
"Coffee beans, sealed sacks",
"Construction rebar bundles",
"Industrial machinery parts",
"Textile rolls",
"Bottled mineral water",
"Refined cooking oil",
"Frozen meat products",
"Pharmaceutical supplies",
"Cement bags",
"Electronics consignment",
"Automotive spare parts",
"Packaged food goods",
];
const handlings = [
"Keep dry. Stack max 3 high.",
"Fragile — handle with care.",
"Temperature-controlled (28°C).",
"Oversized load — escort required.",
"Hazmat class 3 — segregate.",
"Standard handling.",
];
export const consignments: Consignment[] = Array.from({ length: 22 }, (_, i) => {
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
const created = new Date(2026, 4, 1 + (i % 14));
const eta = new Date(created);
eta.setDate(eta.getDate() + 5 + (i % 5));
const cargoType = cargoTypes[i % cargoTypes.length] as ConsignmentCargo;
return {
id,
trackingNumber: `CGM-${String(id).padStart(4, "0")}`,
bookingId: booking.id,
bookingReference: booking.reference,
customer: booking.customer,
originStation: booking.originStation,
destinationStation: booking.destinationStation,
cargoType,
description: descriptions[i % descriptions.length] as string,
weightKg: 500 + (i * 137) % 9500,
volumeM3: Math.round(((i % 10) + 2) * 10) / 10,
pieces: (i % 12) + 1,
hazardous: cargoType === "Hazardous",
specialHandling: handlings[i % handlings.length] as string,
status: statuses[i % statuses.length] as ConsignmentStatus,
createdAt: created.toISOString().slice(0, 10),
estimatedDelivery: eta.toISOString().slice(0, 10),
};
});
export function getConsignmentById(
id: number | string,
): Consignment | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return consignments.find((c) => c.id === numericId);
}

View File

@@ -0,0 +1,245 @@
import { Link, useNavigate, useParams } from "react-router-dom";
import {
ArrowLeft,
Building2,
FileText,
Globe,
Mail,
MapPin,
Phone,
StickyNote,
Trash2,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { getCustomerById, type CustomerStatus } from "./customers.mock";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const customer = id ? getCustomerById(id) : undefined;
if (!customer) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<h1 className="text-2xl font-bold text-slate-900">
Customer not found
</h1>
<p className="mt-2 text-sm text-slate-500">
The customer you're looking for doesn't exist or has been removed.
</p>
<Link
to="/customers"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: customer.name },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
<User className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{customer.name}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span>ID #{customer.id}</span>
<span className="text-slate-300"></span>
<span>{customer.company}</span>
<span className="text-slate-300"></span>
<StatusBadge status={customer.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<NewCustomerPage
mode="edit"
customer={{
companyName: customer.company,
customerType: customer.customerType,
contactPerson: customer.name,
email: customer.email,
phone: customer.phone,
tinNumber: customer.tinNumber,
city: customer.city,
country: customer.country,
address: customer.address,
notes: customer.notes,
}}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
Edit Customer
</button>
</NewCustomerPage>
<DeleteCustomerDialog
customerName={customer.name}
onConfirm={() => navigate("/customers")}
>
<button
type="button"
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</DeleteCustomerDialog>
</div>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Company Information">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Company Name"
value={customer.company}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="Customer Type"
value={customer.customerType}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="TIN Number"
value={customer.tinNumber}
/>
</DetailCard>
<DetailCard title="Contact">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Contact Person"
value={customer.name}
/>
<DetailRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={customer.email}
/>
<DetailRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={customer.phone}
/>
</DetailCard>
<DetailCard title="Location">
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="City"
value={customer.city}
/>
<DetailRow
icon={<Globe className="h-4 w-4" />}
label="Country"
value={customer.country}
/>
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="Address"
value={customer.address}
/>
</DetailCard>
<DetailCard title="Notes">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
<p className="leading-relaxed">{customer.notes}</p>
</div>
</DetailCard>
</div>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#33578D]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "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>
);
}

View File

@@ -1,31 +1,46 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import {
ChevronLeft,
ChevronRight,
Clock3,
Eye,
Filter,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { customers, type CustomerStatus } from "./customers.mock";
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
export default function CustomerPage() {
const customers = [
{
id: 1,
name: 'Abel Tesfaye',
email: 'abel@example.com',
company: 'Addis Logistics',
status: 'Active',
},
{
id: 2,
name: 'Sara Bekele',
email: 'sara@example.com',
company: 'Blue Nile Trading',
status: 'Pending',
},
{
id: 3,
name: 'Henok Alemu',
email: 'henok@example.com',
company: 'Ethio Freight',
status: 'Inactive',
},
];
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const total = customers.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => customers.slice(start, end),
[start, end],
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
@@ -37,16 +52,25 @@ export default function CustomerPage() {
</p>
</div>
<div className="flex items-center gap-3">
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
<Search className="h-4 w-4" />
Search
</button>
<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-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
placeholder="Search customers..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800">
<Plus className="h-4 w-4" />
Add Customer
</button>
<NewCustomerPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
Add Customer
</button>
</NewCustomerPage>
</div>
</div>
@@ -83,7 +107,7 @@ export default function CustomerPage() {
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100">
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
@@ -97,22 +121,20 @@ export default function CustomerPage() {
<th className="px-6 py-4 font-medium">Company</th>
<th className="px-6 py-4 font-medium">Email</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">
Actions
</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{customers.map((customer) => (
{paginated.map((customer) => (
<tr
key={customer.id}
className="border-t border-slate-100 transition hover:bg-slate-50"
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-slate-100">
<User className="h-5 w-5 text-slate-600" />
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
<User className="h-5 w-5" />
</div>
<div>
@@ -140,17 +162,44 @@ export default function CustomerPage() {
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<button className="rounded-xl border border-slate-200 p-2 text-slate-600 hover:bg-slate-100">
<Link
to={`/customers/${customer.id}`}
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</button>
</Link>
<button className="rounded-xl border border-slate-200 p-2 text-slate-600 hover:bg-slate-100">
<Pencil className="h-4 w-4" />
</button>
<NewCustomerPage
mode="edit"
customer={{
companyName: customer.company,
customerType: customer.customerType,
contactPerson: customer.name,
email: customer.email,
phone: customer.phone,
tinNumber: customer.tinNumber,
city: customer.city,
country: customer.country,
address: customer.address,
notes: customer.notes,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewCustomerPage>
<button className="rounded-xl border border-red-200 p-2 text-red-600 hover:bg-red-50">
<Trash2 className="h-4 w-4" />
</button>
<DeleteCustomerDialog customerName={customer.name}>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteCustomerDialog>
</div>
</td>
</tr>
@@ -158,12 +207,114 @@ export default function CustomerPage() {
</tbody>
</table>
</div>
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</div>
);
}
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label htmlFor="page-size" className="font-medium text-slate-700">
Rows per page
</label>
<select
id="page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
@@ -174,16 +325,14 @@ function StatCard({
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{value}
</h3>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-700">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
@@ -191,11 +340,11 @@ function StatCard({
);
}
function StatusBadge({ status }: { status: string }) {
const styles: Record<string, string> = {
Active: 'bg-emerald-100 text-emerald-700',
Pending: 'bg-amber-100 text-amber-700',
Inactive: 'bg-red-100 text-red-700',
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
@@ -206,16 +355,3 @@ function StatusBadge({ status }: { status: string }) {
</span>
);
}
import {
Clock3,
Eye,
Filter,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from 'lucide-react';

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteCustomerDialogProps {
customerName: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteCustomerDialog({
customerName,
onConfirm,
children,
}: DeleteCustomerDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete customer?
</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
from your records. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,223 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
} from "lucide-react";
export interface CustomerFormData {
companyName?: string;
customerType?: string;
contactPerson?: string;
email?: string;
phone?: string;
tinNumber?: string;
city?: string;
country?: string;
address?: string;
notes?: string;
}
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: CustomerFormData;
children?: ReactNode;
}
export default function NewCustomerPage({
mode = "create",
customer,
children,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Company Name */}
<div className="space-y-2">
<Label>Company Name *</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.companyName ?? ""}
placeholder="Enter company name"
className="pl-10"
/>
</div>
</div>
{/* Customer Type */}
<div className="space-y-2">
<Label>Customer Type *</Label>
<select
defaultValue={customer?.customerType ?? "Importer"}
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
<option>Importer</option>
<option>Exporter</option>
<option>Supplier</option>
</select>
</div>
{/* Contact Person */}
<div className="space-y-2">
<Label>Contact Person</Label>
<div className="relative">
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.contactPerson ?? ""}
placeholder="Enter contact person"
className="pl-10"
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label>Email *</Label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
type="email"
defaultValue={customer?.email ?? ""}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<Label>Phone</Label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.phone ?? ""}
placeholder="Enter phone"
className="pl-10"
/>
</div>
</div>
{/* TIN */}
<div className="space-y-2">
<Label>TIN Number</Label>
<div className="relative">
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.tinNumber ?? ""}
placeholder="Enter TIN number"
className="pl-10"
/>
</div>
</div>
{/* City */}
<div className="space-y-2">
<Label>City</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.city ?? ""}
placeholder="Enter city"
className="pl-10"
/>
</div>
</div>
{/* Country */}
<div className="space-y-2">
<Label>Country</Label>
<div className="relative">
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.country ?? ""}
placeholder="Enter country"
className="pl-10"
/>
</div>
</div>
{/* Address */}
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
<Textarea
defaultValue={customer?.address ?? ""}
placeholder="Enter address"
/>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={customer?.notes ?? ""}
placeholder="Additional notes..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,110 @@
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
export interface Customer {
id: number;
name: string;
email: string;
company: string;
status: CustomerStatus;
customerType: CustomerType;
phone: string;
tinNumber: string;
city: string;
country: string;
address: string;
notes: string;
}
const seedCustomers: Customer[] = [
{
id: 1,
name: "Abel Tesfaye",
email: "abel@example.com",
company: "Addis Logistics",
status: "Active",
customerType: "Importer",
phone: "+251 911 234 567",
tinNumber: "0012345678",
city: "Addis Ababa",
country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.",
},
{
id: 2,
name: "Sara Bekele",
email: "sara@example.com",
company: "Blue Nile Trading",
status: "Pending",
customerType: "Exporter",
phone: "+251 922 345 678",
tinNumber: "0023456789",
city: "Dire Dawa",
country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.",
},
{
id: 3,
name: "Henok Alemu",
email: "henok@example.com",
company: "Ethio Freight",
status: "Inactive",
customerType: "Supplier",
phone: "+251 933 456 789",
tinNumber: "0034567890",
city: "Djibouti City",
country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.",
},
];
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
];
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
const generated: Customer[] = extras.map((entry, i) => {
const id = seedCustomers.length + i + 1;
return {
id,
name: entry.name,
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
company: entry.company,
status: statuses[i % statuses.length] as CustomerStatus,
customerType: types[i % types.length] as CustomerType,
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
city: entry.city,
country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`,
};
});
export const customers: Customer[] = [...seedCustomers, ...generated];
export function getCustomerById(id: number | string): Customer | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return customers.find((c) => c.id === numericId);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteDocumentDialogProps {
documentName: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteDocumentDialog({
documentName,
onConfirm,
children,
}: DeleteDocumentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete document?
</DialogTitle>
<DialogDescription>
This will permanently delete{" "}
<span className="font-semibold text-slate-900">{documentName}</span>{" "}
and remove it from object storage. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,630 @@
import { useMemo, useState } from "react";
import {
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
Download,
Eye,
File,
FileImage,
FileSpreadsheet,
FileText,
Filter,
HardDrive,
LayoutGrid,
List,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewDocumentPage from "./NewDocumentPage";
import DeleteDocumentDialog from "./DeleteDocumentDialog";
import {
documents,
formatBytes,
type DocumentFormat,
type DocumentRecord,
type DocumentStatus,
} from "./documents.mock";
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type FilterValue = "All" | DocumentStatus;
type ViewMode = "grid" | "table";
const FILTERS: FilterValue[] = [
"All",
"Draft",
"Pending Review",
"Approved",
"Rejected",
"Expired",
];
export default function DocumentsPage() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return documents.filter((d) => {
if (filter !== "All" && d.status !== filter) return false;
if (!q) return true;
return (
d.name.toLowerCase().includes(q) ||
d.type.toLowerCase().includes(q) ||
d.linkedReference.toLowerCase().includes(q) ||
d.uploadedBy.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => filtered.slice(start, end),
[filtered, start, end],
);
const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
const approvedCount = documents.filter((d) => d.status === "Approved").length;
const pendingCount = documents.filter(
(d) => d.status === "Pending Review",
).length;
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Documents" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Documents
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage freight documents linked to bookings, consignments, and
invoices.
</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-3 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);
setPage(1);
}}
placeholder="Search documents..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewDocumentPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
Upload Document
</button>
</NewDocumentPage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Total Documents"
value={String(documents.length)}
icon={<FileText className="h-5 w-5" />}
/>
<StatCard
title="Approved"
value={String(approvedCount)}
icon={<CheckCircle2 className="h-5 w-5" />}
/>
<StatCard
title="Pending Review"
value={String(pendingCount)}
icon={<Clock className="h-5 w-5" />}
/>
<StatCard
title="Storage Used"
value={formatBytes(totalSize)}
icon={<HardDrive className="h-5 w-5" />}
/>
</div>
{/* Filter tabs + view toggle */}
<div className="flex flex-col gap-3 rounded-3xl bg-white p-2 shadow-sm 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"
? documents.length
: documents.filter((d) => d.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPage(1);
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{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 className="h-4 w-4" />
<span className="hidden sm:inline">Grid</span>
</ViewToggleButton>
<ViewToggleButton
active={view === "table"}
onClick={() => setView("table")}
label="Table view"
>
<List className="h-4 w-4" />
<span className="hidden sm:inline">Table</span>
</ViewToggleButton>
</div>
</div>
{/* Empty / Grid / Table */}
{paginated.length === 0 ? (
<div className="rounded-3xl bg-white p-12 text-center shadow-sm">
<p className="text-sm text-slate-500">
No documents match your filters.
</p>
</div>
) : view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginated.map((doc) => (
<DocumentCard key={doc.id} doc={doc} />
))}
</div>
) : (
<DocumentTable documents={paginated} />
)}
{filtered.length > 0 ? (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
) : null}
</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-[#33578D] 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-[#33578D]"
}
>
{children}
</button>
);
}
function FormatIcon({ format }: { format: DocumentFormat }) {
const className = "h-5 w-5 text-[#33578D]";
if (format === "PDF") return <FileText className={className} />;
if (format === "DOCX") return <FileText className={className} />;
if (format === "XLSX") return <FileSpreadsheet className={className} />;
if (format === "PNG" || format === "JPG")
return <FileImage className={className} />;
return <File className={className} />;
}
function DocumentCard({ doc }: { doc: DocumentRecord }) {
return (
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10">
<FormatIcon format={doc.format} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-slate-900">
{doc.name}
</p>
<p className="text-xs text-slate-500">{doc.type}</p>
</div>
</div>
<StatusBadge status={doc.status} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<MetaRow label="Linked to" value={`${doc.linkedType} · ${doc.linkedReference}`} />
<MetaRow label="Format" value={doc.format} />
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} />
<MetaRow label="Uploaded" value={doc.uploadedAt} />
</div>
<p className="text-xs text-slate-500">By {doc.uploadedBy}</p>
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-3">
<button
type="button"
aria-label="Preview"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Download"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Download className="h-4 w-4" />
</button>
<NewDocumentPage
mode="edit"
document={{
name: doc.name,
type: doc.type,
status: doc.status,
linkedType: doc.linkedType,
linkedReference: doc.linkedReference,
notes: doc.notes,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewDocumentPage>
<DeleteDocumentDialog documentName={doc.name}>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteDocumentDialog>
</div>
</div>
);
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-slate-500">{label}</p>
<p className="mt-0.5 text-sm font-medium text-slate-900">{value}</p>
</div>
);
}
function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
return (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Document Library
</h2>
<p className="text-sm text-slate-500">
All freight documents stored in the system.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Document</th>
<th className="px-6 py-4 font-medium">Type</th>
<th className="px-6 py-4 font-medium">Linked To</th>
<th className="px-6 py-4 font-medium">Size</th>
<th className="px-6 py-4 font-medium">Uploaded</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{rows.map((doc) => (
<tr
key={doc.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10">
<FormatIcon format={doc.format} />
</div>
<div className="min-w-0">
<p className="truncate font-medium text-slate-900">
{doc.name}
</p>
<p className="text-xs text-slate-500">
{doc.format} · By {doc.uploadedBy}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">{doc.type}</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div>
<p>{doc.linkedReference}</p>
<p className="text-xs text-slate-500">{doc.linkedType}</p>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{formatBytes(doc.sizeBytes)}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{doc.uploadedAt}
</td>
<td className="px-6 py-4">
<StatusBadge status={doc.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<button
type="button"
aria-label="Preview"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
aria-label="Download"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Download className="h-4 w-4" />
</button>
<NewDocumentPage
mode="edit"
document={{
name: doc.name,
type: doc.type,
status: doc.status,
linkedType: doc.linkedType,
linkedReference: doc.linkedReference,
notes: doc.notes,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewDocumentPage>
<DeleteDocumentDialog documentName={doc.name}>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteDocumentDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label
htmlFor="document-page-size"
className="font-medium text-slate-700"
>
Rows per page
</label>
<select
id="document-page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: DocumentStatus }) {
const styles: Record<DocumentStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
"Pending Review": "bg-amber-100 text-amber-700",
Approved: "bg-emerald-100 text-emerald-700",
Rejected: "bg-red-100 text-red-700",
Expired: "bg-slate-200 text-slate-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -0,0 +1,242 @@
import { useState, type ReactNode } from "react";
import { FileUp, Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
import { customers } from "../customers/customers.mock";
import type {
DocumentLinkType,
DocumentStatus,
DocumentType,
} from "./documents.mock";
export interface DocumentFormData {
name?: string;
type?: DocumentType;
status?: DocumentStatus;
linkedType?: DocumentLinkType;
linkedReference?: string;
notes?: string;
}
export interface NewDocumentPageProps {
mode?: "create" | "edit";
document?: DocumentFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
export default function NewDocumentPage({
mode = "create",
document,
children,
}: NewDocumentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Document" : "Upload Document";
const description = isEdit
? "Update document metadata."
: "Upload a freight document and link it to a booking, consignment, or invoice.";
const submitLabel = isEdit ? "Save Changes" : "Upload";
const [linkedType, setLinkedType] = useState<DocumentLinkType>(
document?.linkedType ?? "Booking",
);
const [fileName, setFileName] = useState<string>("");
const referenceOptions = (() => {
if (linkedType === "Booking") {
return bookings.map((b) => ({
value: b.reference,
label: `${b.reference}${b.customer}`,
}));
}
if (linkedType === "Consignment") {
return consignments.map((c) => ({
value: c.trackingNumber,
label: `${c.trackingNumber}${c.customer}`,
}));
}
if (linkedType === "Customer") {
return customers.map((c) => ({
value: c.company,
label: c.company,
}));
}
return [] as Array<{ value: string; label: string }>;
})();
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "Upload Document"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* File picker — drop zone */}
{!isEdit ? (
<div className="md:col-span-2">
<Label>File</Label>
<label
htmlFor="document-file-input"
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#33578D]/5 p-8 text-center transition hover:border-[#33578D]/40 hover:bg-[#33578D]/10"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
<FileUp className="h-6 w-6" />
</div>
<p className="text-sm font-medium text-slate-900">
{fileName || "Click to choose a file or drag it here"}
</p>
<p className="text-xs text-slate-500">
PDF, DOCX, XLSX, PNG, JPG · max 25 MB
</p>
<input
id="document-file-input"
type="file"
className="hidden"
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
onChange={(e) =>
setFileName(e.target.files?.[0]?.name ?? "")
}
/>
</label>
</div>
) : null}
{/* Document Name */}
<div className="space-y-2">
<Label>Document Name *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={document?.name ?? ""}
placeholder="e.g. bill-of-lading-bk-026003.pdf"
className="pl-10"
/>
</div>
</div>
{/* Document Type */}
<div className="space-y-2">
<Label>Document Type *</Label>
<select
defaultValue={document?.type ?? "Bill of Lading"}
className={selectClass}
>
<option>Bill of Lading</option>
<option>Commercial Invoice</option>
<option>Packing List</option>
<option>Customs Declaration</option>
<option>Certificate of Origin</option>
<option>Insurance Certificate</option>
<option>Delivery Receipt</option>
<option>Proof of Delivery</option>
<option>Contract</option>
<option>Other</option>
</select>
</div>
{/* Linked To */}
<div className="space-y-2">
<Label>Linked To</Label>
<select
value={linkedType}
onChange={(e) =>
setLinkedType(e.target.value as DocumentLinkType)
}
className={selectClass}
>
<option>Booking</option>
<option>Consignment</option>
<option>Shipment</option>
<option>Customer</option>
<option>Invoice</option>
<option>None</option>
</select>
</div>
{/* Reference */}
<div className="space-y-2">
<Label>Reference</Label>
{referenceOptions.length > 0 ? (
<select
defaultValue={document?.linkedReference ?? ""}
className={selectClass}
>
<option value="" disabled>
Select {linkedType.toLowerCase()}
</option>
{referenceOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : (
<Input
defaultValue={document?.linkedReference ?? ""}
placeholder={
linkedType === "None"
? "Not linked"
: `Enter ${linkedType.toLowerCase()} reference`
}
disabled={linkedType === "None"}
/>
)}
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={document?.status ?? "Draft"}
className={selectClass}
>
<option>Draft</option>
<option>Pending Review</option>
<option>Approved</option>
<option>Rejected</option>
<option>Expired</option>
</select>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={document?.notes ?? ""}
placeholder="Any extra context for this document..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,140 @@
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
export type DocumentType =
| "Bill of Lading"
| "Commercial Invoice"
| "Packing List"
| "Customs Declaration"
| "Certificate of Origin"
| "Insurance Certificate"
| "Delivery Receipt"
| "Proof of Delivery"
| "Contract"
| "Other";
export type DocumentFormat = "PDF" | "DOCX" | "XLSX" | "PNG" | "JPG";
export type DocumentStatus =
| "Draft"
| "Pending Review"
| "Approved"
| "Rejected"
| "Expired";
export type DocumentLinkType =
| "Booking"
| "Consignment"
| "Shipment"
| "Customer"
| "Invoice"
| "None";
export interface DocumentRecord {
id: number;
name: string;
type: DocumentType;
format: DocumentFormat;
sizeBytes: number;
linkedType: DocumentLinkType;
linkedReference: string;
uploadedBy: string;
uploadedAt: string;
status: DocumentStatus;
notes: string;
/** Object key — meant for the future MinIO bucket. */
objectKey: string;
}
const types: DocumentType[] = [
"Bill of Lading",
"Commercial Invoice",
"Packing List",
"Customs Declaration",
"Certificate of Origin",
"Insurance Certificate",
"Delivery Receipt",
"Proof of Delivery",
"Contract",
"Other",
];
const formats: DocumentFormat[] = ["PDF", "DOCX", "XLSX", "PNG", "JPG"];
const statuses: DocumentStatus[] = [
"Draft",
"Pending Review",
"Approved",
"Rejected",
"Expired",
];
const uploaders = [
"John Doe",
"Sarah Bekele",
"Michael Chen",
"Aisha Mohamed",
"Daniel Worku",
];
function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
const slug = type.toLowerCase().replace(/\s+/g, "-");
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
}
export const documents: DocumentRecord[] = Array.from({ length: 24 }, (_, i) => {
const id = i + 1;
const type = types[i % types.length] as DocumentType;
const format = formats[i % formats.length] as DocumentFormat;
const status = statuses[i % statuses.length] as DocumentStatus;
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
const linkPick = i % 3;
let linkedType: DocumentLinkType;
let linkedReference: string;
if (linkPick === 0) {
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
linkedType = "Booking";
linkedReference = booking.reference;
} else if (linkPick === 1) {
const consignment = consignments[
i % consignments.length
] as (typeof consignments)[number];
linkedType = "Consignment";
linkedReference = consignment.trackingNumber;
} else {
linkedType = "Invoice";
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
}
const name = fileNameFor(type, linkedReference, format);
return {
id,
name,
type,
format,
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
linkedType,
linkedReference,
uploadedBy: uploaders[i % uploaders.length] as string,
uploadedAt: uploadedAt.toISOString().slice(0, 10),
status,
notes:
i % 3 === 0
? "Original signed copy."
: i % 3 === 1
? "Scanned from physical document."
: "Generated by system.",
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
};
});
export function getDocumentById(id: number | string): DocumentRecord | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return documents.find((d) => d.id === numericId);
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}

View File

@@ -0,0 +1,63 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteShipmentDialogProps {
shipmentReference: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteShipmentDialog({
shipmentReference,
onConfirm,
children,
}: DeleteShipmentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Remove shipment?
</DialogTitle>
<DialogDescription>
This will permanently remove shipment{" "}
<span className="font-semibold text-slate-900">
{shipmentReference}
</span>{" "}
from tracking. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Remove
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,171 @@
import type { ReactNode } from "react";
import { Calendar, MapPin } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { bookings } from "../bookings/bookings.mock";
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
export interface ShipmentFormData {
bookingId?: number;
bookingReference?: string;
originStation?: string;
destinationStation?: string;
mode?: ShipmentMode;
status?: ShipmentStatus;
currentLocation?: string;
eta?: string;
}
export interface NewShipmentPageProps {
mode?: "create" | "edit";
shipment?: ShipmentFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
export default function NewShipmentPage({
mode = "create",
shipment,
children,
}: NewShipmentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Shipment" : "New Shipment";
const description = isEdit
? "Update shipment tracking information."
: "Create a new shipment for real-time tracking.";
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Freight Booking */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<select
defaultValue={shipment?.bookingId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
</option>
))}
</select>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={shipment?.status ?? "In Transit"}
className={selectClass}
>
<option>In Transit</option>
<option>Delivered</option>
<option>Delayed</option>
</select>
</div>
{/* Mode */}
<div className="space-y-2">
<Label>Transport Mode</Label>
<select
defaultValue={shipment?.mode ?? "rail"}
className={selectClass}
>
<option value="rail">Rail</option>
<option value="truck">Truck</option>
<option value="multimodal">Multimodal</option>
</select>
</div>
{/* Origin */}
<div className="space-y-2">
<Label>Origin Station</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.originStation ?? ""}
placeholder="Auto-filled from booking"
className="pl-10"
/>
</div>
</div>
{/* Destination */}
<div className="space-y-2">
<Label>Destination Station</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.destinationStation ?? ""}
placeholder="Auto-filled from booking"
className="pl-10"
/>
</div>
</div>
{/* ETA */}
<div className="space-y-2">
<Label>Estimated Arrival</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={shipment?.eta ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Current Location */}
<div className="space-y-2">
<Label>Current Location</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={shipment?.currentLocation ?? ""}
placeholder="e.g. Dire Dawa Yard"
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,33 +1,462 @@
import { useState } from "react";
import { Button, FormField } from "@edr/ui-common";
import { useMemo, useState } from "react";
import {
ArrowRight,
Building2,
Clock,
LayoutGrid,
List,
MapPin,
Pencil,
Plus,
Search,
Train,
Trash2,
Truck,
} from "lucide-react";
import TrackingTimeline from "../../components/tracking/TrackingTimeline";
import { useTracking } from "../../hooks/useTracking";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewShipmentPage from "./NewShipmentPage";
import DeleteShipmentDialog from "./DeleteShipmentDialog";
import {
shipments,
type Shipment,
type ShipmentMode,
type ShipmentStatus,
} from "./shipments.mock";
const TrackingPage = () => {
const [consignmentId, setConsignmentId] = useState("");
const [activeId, setActiveId] = useState("");
const { data, isFetching } = useTracking(activeId);
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.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="flex max-w-2xl flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Tracking</h1>
<div className="flex items-end gap-3">
<FormField
label="Consignment ID"
value={consignmentId}
onChange={(event) => setConsignmentId(event.target.value)}
placeholder="UUID"
/>
<Button onClick={() => setActiveId(consignmentId)}>Look up</Button>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Tracking" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Shipment Tracking
</h1>
<p className="mt-1 text-sm text-slate-500">
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-3 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="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewShipmentPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Shipment
</button>
</NewShipmentPage>
</div>
</div>
{/* Filter tabs + view toggle */}
<div className="flex flex-col gap-3 rounded-3xl bg-white p-2 shadow-sm 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-[#33578D] px-4 py-2 text-sm font-medium text-white"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{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 className="h-4 w-4" />
<span className="hidden sm:inline">Grid</span>
</ViewToggleButton>
<ViewToggleButton
active={view === "table"}
onClick={() => setView("table")}
label="Table view"
>
<List className="h-4 w-4" />
<span className="hidden sm:inline">Table</span>
</ViewToggleButton>
</div>
</div>
{/* Empty / Grid / Table */}
{filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-12 text-center shadow-sm">
<p className="text-sm text-slate-500">
No shipments match your filters.
</p>
</div>
) : 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>
{isFetching ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<TrackingTimeline events={data ?? []} />
)}
</div>
);
};
}
export default TrackingPage;
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-[#33578D] 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-[#33578D]"
}
>
{children}
</button>
);
}
function ShipmentCard({ shipment }: { shipment: Shipment }) {
return (
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<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">
Booking {shipment.bookingReference}
</span>
</div>
<StatusBadge status={shipment.status} />
</div>
{/* Route */}
<div className="flex items-center justify-between rounded-2xl bg-[#33578D]/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="h-5 w-5 text-[#33578D]" />
<div className="text-sm text-right">
<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>
{/* Progress */}
<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-[#33578D] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
{/* Meta */}
<div className="space-y-2 text-sm text-slate-700">
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-[#33578D]" />
<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 className="h-4 w-4 text-[#33578D]" />
<span>{shipment.currentLocation}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Clock className="h-3.5 w-3.5" />
<span>
Updated {shipment.lastUpdate} · ETA {shipment.eta}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-3">
<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,
}}
>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
</NewShipmentPage>
<DeleteShipmentDialog shipmentReference={shipment.reference}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
Remove
</button>
</DeleteShipmentDialog>
</div>
</div>
);
}
function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
return (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<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 border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10 text-[#33578D]">
<Truck className="h-5 w-5" />
</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="h-3.5 w-3.5 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-[#33578D]"
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 className="h-3.5 w-3.5 text-[#33578D]" />
{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 className="flex justify-end gap-2">
<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,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewShipmentPage>
<DeleteShipmentDialog
shipmentReference={shipment.reference}
>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteShipmentDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
function ModeIcon({ mode }: { mode: ShipmentMode }) {
if (mode === "rail") return <Train className="h-4 w-4 text-[#33578D]" />;
if (mode === "truck") return <Truck className="h-4 w-4 text-[#33578D]" />;
return <Train className="h-4 w-4 text-[#33578D]" />;
}
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>
);
}

View File

@@ -0,0 +1,151 @@
import { bookings } from "../bookings/bookings.mock";
export type ShipmentStatus = "In Transit" | "Delivered" | "Delayed";
export type ShipmentMode = "rail" | "truck" | "multimodal";
export interface Shipment {
id: number;
reference: string;
bookingId: number;
bookingReference: string;
customer: string;
originStation: string;
destinationStation: string;
mode: ShipmentMode;
status: ShipmentStatus;
currentLocation: string;
eta: string;
lastUpdate: string;
progress: number;
}
const seedShipments: Array<{
status: ShipmentStatus;
currentLocation: string;
eta: string;
lastUpdate: string;
progress: number;
mode: ShipmentMode;
}> = [
{
status: "In Transit",
currentLocation: "Awash Junction",
eta: "2026-05-18",
lastUpdate: "2 hours ago",
progress: 45,
mode: "rail",
},
{
status: "Delivered",
currentLocation: "Dire Dawa Depot",
eta: "2026-05-12",
lastUpdate: "Yesterday",
progress: 100,
mode: "truck",
},
{
status: "In Transit",
currentLocation: "Dire Dawa Yard",
eta: "2026-05-17",
lastUpdate: "30 minutes ago",
progress: 70,
mode: "rail",
},
{
status: "Delayed",
currentLocation: "Mieso",
eta: "2026-05-19",
lastUpdate: "5 hours ago",
progress: 35,
mode: "rail",
},
{
status: "Delivered",
currentLocation: "Adama Logistics Hub",
eta: "2026-05-10",
lastUpdate: "3 days ago",
progress: 100,
mode: "truck",
},
{
status: "In Transit",
currentLocation: "Mieso Crossing",
eta: "2026-05-20",
lastUpdate: "1 hour ago",
progress: 55,
mode: "multimodal",
},
{
status: "Delayed",
currentLocation: "Debre Markos",
eta: "2026-05-16",
lastUpdate: "6 hours ago",
progress: 60,
mode: "truck",
},
{
status: "In Transit",
currentLocation: "Awash Junction",
eta: "2026-05-21",
lastUpdate: "45 minutes ago",
progress: 40,
mode: "multimodal",
},
{
status: "Delivered",
currentLocation: "Djibouti Port",
eta: "2026-05-09",
lastUpdate: "5 days ago",
progress: 100,
mode: "rail",
},
{
status: "In Transit",
currentLocation: "Aysha Border",
eta: "2026-05-19",
lastUpdate: "20 minutes ago",
progress: 80,
mode: "rail",
},
{
status: "Delivered",
currentLocation: "Addis Ababa Warehouse",
eta: "2026-05-11",
lastUpdate: "4 days ago",
progress: 100,
mode: "truck",
},
{
status: "Delayed",
currentLocation: "Dire Dawa Yard",
eta: "2026-05-22",
lastUpdate: "12 hours ago",
progress: 50,
mode: "rail",
},
];
export const shipments: Shipment[] = seedShipments.map((entry, i) => {
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
const id = i + 1;
return {
id,
reference: `SH-${String(id).padStart(3, "0")}`,
bookingId: booking.id,
bookingReference: booking.reference,
customer: booking.customer,
originStation: booking.originStation,
destinationStation: booking.destinationStation,
mode: entry.mode,
status: entry.status,
currentLocation: entry.currentLocation,
eta: entry.eta,
lastUpdate: entry.lastUpdate,
progress: entry.progress,
};
});
export function getShipmentById(id: number | string): Shipment | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return shipments.find((s) => s.id === numericId);
}

View File

@@ -0,0 +1,61 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteTrainDialogProps {
trainCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteTrainDialog({
trainCode,
onConfirm,
children,
}: DeleteTrainDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Retire train?
</DialogTitle>
<DialogDescription>
This will permanently retire train{" "}
<span className="font-semibold text-slate-900">{trainCode}</span>{" "}
from the fleet. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Retire
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,240 @@
import type { ReactNode } from "react";
import {
Building2,
Calendar,
Factory,
Gauge,
MapPin,
Train as TrainIcon,
Weight,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type { TrainStatus, TrainType } from "./trains.mock";
export interface TrainFormData {
code?: string;
name?: string;
type?: TrainType;
status?: TrainStatus;
capacityTons?: number;
depot?: string;
manufacturer?: string;
mileageKm?: number;
lastMaintenance?: string;
nextMaintenance?: string;
currentAssignment?: string;
yearBuilt?: number;
}
export interface NewTrainPageProps {
mode?: "create" | "edit";
train?: TrainFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
export default function NewTrainPage({
mode = "create",
train,
children,
}: NewTrainPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Train" : "New Train";
const description = isEdit
? "Update train fleet information."
: "Add a new train to the fleet roster.";
const submitLabel = isEdit ? "Save Changes" : "Add Train";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Train"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Code */}
<div className="space-y-2">
<Label>Train Code *</Label>
<div className="relative">
<TrainIcon className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.code ?? ""}
placeholder="e.g. LOC-001"
className="pl-10"
/>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label>Name</Label>
<Input
defaultValue={train?.name ?? ""}
placeholder="e.g. Awash Express"
/>
</div>
{/* Type */}
<div className="space-y-2">
<Label>Type *</Label>
<select
defaultValue={train?.type ?? "Locomotive"}
className={selectClass}
>
<option>Locomotive</option>
<option>Freight Wagon</option>
<option>Tanker Wagon</option>
<option>Container Wagon</option>
<option>Reefer Wagon</option>
</select>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={train?.status ?? "Operational"}
className={selectClass}
>
<option>Operational</option>
<option>In Maintenance</option>
<option>Idle</option>
<option>Out of Service</option>
</select>
</div>
{/* Capacity */}
<div className="space-y-2">
<Label>Capacity (Tons)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.capacityTons ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Depot */}
<div className="space-y-2">
<Label>Depot</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.depot ?? ""}
placeholder="e.g. Addis Ababa"
className="pl-10"
/>
</div>
</div>
{/* Manufacturer */}
<div className="space-y-2">
<Label>Manufacturer</Label>
<div className="relative">
<Factory className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.manufacturer ?? ""}
placeholder="e.g. CRRC Zhuzhou"
className="pl-10"
/>
</div>
</div>
{/* Year Built */}
<div className="space-y-2">
<Label>Year Built</Label>
<Input
type="number"
min={1950}
max={2030}
defaultValue={train?.yearBuilt ?? 2020}
/>
</div>
{/* Mileage */}
<div className="space-y-2">
<Label>Mileage (km)</Label>
<div className="relative">
<Gauge className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.mileageKm ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Current Assignment */}
<div className="space-y-2">
<Label>Current Assignment</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.currentAssignment ?? ""}
placeholder="e.g. BK-026003 or —"
className="pl-10"
/>
</div>
</div>
{/* Last Maintenance */}
<div className="space-y-2">
<Label>Last Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.lastMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Next Maintenance */}
<div className="space-y-2">
<Label>Next Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.nextMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,11 +1,444 @@
const TrainsPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Trains</h1>
<p className="text-sm text-gray-600">
Fleet roster, capacity, and maintenance status. Connect to{" "}
<code>/trains</code> when ready.
</p>
</div>
);
import { useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Filter,
Gauge,
Pencil,
Plus,
Search,
Train as TrainIcon,
Trash2,
Wrench,
} from "lucide-react";
export default TrainsPage;
import Breadcrumbs from "@/components/Breadcrumbs";
import NewTrainPage from "./NewTrainPage";
import DeleteTrainDialog from "./DeleteTrainDialog";
import { trains, type TrainStatus } from "./trains.mock";
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type FilterValue = "All" | TrainStatus;
const FILTERS: FilterValue[] = [
"All",
"Operational",
"In Maintenance",
"Idle",
"Out of Service",
];
export default function TrainsPage() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return trains.filter((t) => {
if (filter !== "All" && t.status !== filter) return false;
if (!q) return true;
return (
t.code.toLowerCase().includes(q) ||
t.name.toLowerCase().includes(q) ||
t.depot.toLowerCase().includes(q) ||
t.type.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
const end = Math.min(start + pageSize, total);
const paginated = useMemo(
() => filtered.slice(start, end),
[filtered, start, end],
);
const operationalCount = trains.filter(
(t) => t.status === "Operational",
).length;
const maintenanceCount = trains.filter(
(t) => t.status === "In Maintenance",
).length;
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Trains" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Trains
</h1>
<p className="mt-1 text-sm text-slate-500">
Fleet roster, capacity, and maintenance status.
</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-3 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);
setPage(1);
}}
placeholder="Search trains..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<NewTrainPage>
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Train
</button>
</NewTrainPage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Trains"
value={String(trains.length)}
icon={<TrainIcon className="h-5 w-5" />}
/>
<StatCard
title="Operational"
value={String(operationalCount)}
icon={<Gauge className="h-5 w-5" />}
/>
<StatCard
title="In Maintenance"
value={String(maintenanceCount)}
icon={<Wrench className="h-5 w-5" />}
/>
</div>
{/* Filter tabs */}
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? trains.length
: trains.filter((t) => t.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPage(1);
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{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>
{/* Train Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Fleet Roster
</h2>
<p className="text-sm text-slate-500">
All locomotives and wagons in service.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Train</th>
<th className="px-6 py-4 font-medium">Type</th>
<th className="px-6 py-4 font-medium">Capacity</th>
<th className="px-6 py-4 font-medium">Depot</th>
<th className="px-6 py-4 font-medium">Mileage</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
No trains match your filters.
</td>
</tr>
) : (
paginated.map((train) => (
<tr
key={train.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<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-[#33578D]/10 text-[#33578D]">
<TrainIcon className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{train.code}
</p>
<p className="text-sm text-slate-500">
{train.name}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{train.type}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{train.capacityTons}t
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{train.depot}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{train.mileageKm.toLocaleString()} km
</td>
<td className="px-6 py-4">
<StatusBadge status={train.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<NewTrainPage
mode="edit"
train={{
code: train.code,
name: train.name,
type: train.type,
status: train.status,
capacityTons: train.capacityTons,
depot: train.depot,
manufacturer: train.manufacturer,
mileageKm: train.mileageKm,
lastMaintenance: train.lastMaintenance,
nextMaintenance: train.nextMaintenance,
currentAssignment: train.currentAssignment,
yearBuilt: train.yearBuilt,
}}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<Pencil className="h-4 w-4" />
</button>
</NewTrainPage>
<DeleteTrainDialog trainCode={train.code}>
<button
type="button"
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteTrainDialog>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<Pagination
page={safePage}
pageSize={pageSize}
total={total}
totalPages={totalPages}
start={start}
end={end}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
/>
</div>
</div>
</div>
);
}
function Pagination({
page,
pageSize,
total,
totalPages,
start,
end,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageSize: number;
total: number;
totalPages: number;
start: number;
end: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
}) {
const showingFrom = total === 0 ? 0 : start + 1;
return (
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3 text-sm text-slate-500">
<label htmlFor="train-page-size" className="font-medium text-slate-700">
Rows per page
</label>
<select
id="train-page-size"
value={pageSize}
onChange={(e) => onPageSizeChange(Number(e.target.value))}
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
>
{PAGE_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}
</option>
))}
</select>
<span>
Showing {showingFrom}{end} of {total}
</span>
</div>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
<ChevronLeft className="h-4 w-4" />
Prev
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
const isActive = p === page;
return (
<button
key={p}
type="button"
onClick={() => onPageChange(p)}
aria-current={isActive ? "page" : undefined}
className={
isActive
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
}
>
{p}
</button>
);
})}
<button
type="button"
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
>
Next
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: TrainStatus }) {
const styles: Record<TrainStatus, string> = {
Operational: "bg-emerald-100 text-emerald-700",
"In Maintenance": "bg-amber-100 text-amber-700",
Idle: "bg-slate-100 text-slate-600",
"Out of Service": "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>
);
}

View File

@@ -0,0 +1,263 @@
export type TrainType =
| "Locomotive"
| "Freight Wagon"
| "Tanker Wagon"
| "Container Wagon"
| "Reefer Wagon";
export type TrainStatus =
| "Operational"
| "In Maintenance"
| "Idle"
| "Out of Service";
export interface Train {
id: number;
code: string;
name: string;
type: TrainType;
status: TrainStatus;
capacityTons: number;
depot: string;
manufacturer: string;
mileageKm: number;
lastMaintenance: string;
nextMaintenance: string;
currentAssignment: string;
yearBuilt: number;
}
const seedTrains: Array<Omit<Train, "id" | "code">> = [
{
name: "Awash Express",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 184320,
lastMaintenance: "2026-04-12",
nextMaintenance: "2026-07-12",
currentAssignment: "BK-026001",
yearBuilt: 2018,
},
{
name: "Rift Valley Hauler",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Adama",
manufacturer: "CRRC Zhuzhou",
mileageKm: 156780,
lastMaintenance: "2026-03-28",
nextMaintenance: "2026-06-28",
currentAssignment: "BK-026004",
yearBuilt: 2019,
},
{
name: "Djibouti Freighter",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Dire Dawa",
manufacturer: "CRRC Yangtze",
mileageKm: 92110,
lastMaintenance: "2026-04-02",
nextMaintenance: "2026-08-02",
currentAssignment: "BK-026003",
yearBuilt: 2020,
},
{
name: "Highlander 1",
type: "Freight Wagon",
status: "In Maintenance",
capacityTons: 80,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 211450,
lastMaintenance: "2026-05-10",
nextMaintenance: "2026-05-20",
currentAssignment: "—",
yearBuilt: 2017,
},
{
name: "Highlander 2",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 198020,
lastMaintenance: "2026-04-18",
nextMaintenance: "2026-07-18",
currentAssignment: "BK-026007",
yearBuilt: 2017,
},
{
name: "Sheba Tanker",
type: "Tanker Wagon",
status: "Operational",
capacityTons: 70,
depot: "Awash",
manufacturer: "CRRC Zhuzhou",
mileageKm: 132540,
lastMaintenance: "2026-04-05",
nextMaintenance: "2026-07-05",
currentAssignment: "BK-026010",
yearBuilt: 2019,
},
{
name: "Lalibela Cooler",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 67890,
lastMaintenance: "2026-03-22",
nextMaintenance: "2026-06-22",
currentAssignment: "—",
yearBuilt: 2021,
},
{
name: "Awash Express II",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Mieso",
manufacturer: "CRRC Zhuzhou",
mileageKm: 145600,
lastMaintenance: "2026-04-22",
nextMaintenance: "2026-07-22",
currentAssignment: "BK-026013",
yearBuilt: 2019,
},
{
name: "Coffee Belt Wagon",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 88240,
lastMaintenance: "2026-04-09",
nextMaintenance: "2026-08-09",
currentAssignment: "BK-026016",
yearBuilt: 2020,
},
{
name: "Red Sea Hauler",
type: "Locomotive",
status: "Out of Service",
capacityTons: 240,
depot: "Djibouti City",
manufacturer: "CRRC Zhuzhou",
mileageKm: 264100,
lastMaintenance: "2026-02-14",
nextMaintenance: "2026-08-14",
currentAssignment: "—",
yearBuilt: 2015,
},
{
name: "Ali Sabieh Express",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Ali Sabieh",
manufacturer: "CRRC Yangtze",
mileageKm: 102330,
lastMaintenance: "2026-04-15",
nextMaintenance: "2026-07-15",
currentAssignment: "BK-026019",
yearBuilt: 2020,
},
{
name: "Holhol Tanker",
type: "Tanker Wagon",
status: "In Maintenance",
capacityTons: 70,
depot: "Holhol",
manufacturer: "CRRC Zhuzhou",
mileageKm: 178600,
lastMaintenance: "2026-05-08",
nextMaintenance: "2026-05-22",
currentAssignment: "—",
yearBuilt: 2018,
},
{
name: "Aysha Carrier",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Aysha",
manufacturer: "CRRC Yangtze",
mileageKm: 75940,
lastMaintenance: "2026-04-19",
nextMaintenance: "2026-08-19",
currentAssignment: "BK-026022",
yearBuilt: 2021,
},
{
name: "Mojo Reefer",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 49870,
lastMaintenance: "2026-04-01",
nextMaintenance: "2026-07-01",
currentAssignment: "—",
yearBuilt: 2022,
},
{
name: "Simien Locomotive",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 121340,
lastMaintenance: "2026-04-25",
nextMaintenance: "2026-07-25",
currentAssignment: "BK-026008",
yearBuilt: 2020,
},
{
name: "Gibe Freight",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 168200,
lastMaintenance: "2026-04-11",
nextMaintenance: "2026-07-11",
currentAssignment: "BK-026011",
yearBuilt: 2018,
},
];
export const trains: Train[] = seedTrains.map((entry, i) => {
const id = i + 1;
const prefix =
entry.type === "Locomotive"
? "LOC"
: entry.type === "Tanker Wagon"
? "TNK"
: entry.type === "Reefer Wagon"
? "RFR"
: entry.type === "Container Wagon"
? "CNT"
: "WGN";
return {
id,
code: `${prefix}-${String(id).padStart(3, "0")}`,
...entry,
};
});
export function getTrainById(id: number | string): Train | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return trains.find((t) => t.id === numericId);
}

View File

@@ -3,7 +3,11 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"useDefineForClassFields": true,
"skipLibCheck": true
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,9 +1,19 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
host: "0.0.0.0",

View File

@@ -1,4 +1,13 @@
import { ReactNode } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import {
Bell,
ChevronDown,
Languages,
LogOut,
Moon,
Sun,
User,
} from "lucide-react";
import Sidebar, { SidebarItem } from "./Sidebar";
export interface DashboardLayoutProps {
@@ -7,32 +16,195 @@ export interface DashboardLayoutProps {
activeHref?: string;
onNavigate?: (href: string) => void;
headerRight?: ReactNode;
enableThemeToggle?: boolean;
children: ReactNode;
}
type Theme = "light" | "dark";
const THEME_STORAGE_KEY = "edr-theme";
function getInitialTheme(): Theme {
if (typeof window === "undefined") return "light";
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored;
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const iconButtonClass =
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#33578D]/40 dark:hover:bg-[#33578D]/20 dark:hover:text-white";
const DashboardLayout = ({
title,
sidebarItems,
activeHref,
onNavigate,
headerRight,
enableThemeToggle = false,
children,
}: DashboardLayoutProps) => (
<div className="flex min-h-screen bg-gray-50">
<Sidebar
title={title}
items={sidebarItems}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<div className="flex flex-1 flex-col">
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<div className="text-sm font-medium text-gray-700">{title}</div>
<div>{headerRight}</div>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
}: DashboardLayoutProps) => {
const [theme, setTheme] = useState<Theme>(() =>
enableThemeToggle ? getInitialTheme() : "light",
);
useEffect(() => {
if (!enableThemeToggle) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
}, [theme, enableThemeToggle]);
const toggleTheme = () =>
setTheme((current) => (current === "dark" ? "light" : "dark"));
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const userMenuRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!isUserMenuOpen) return;
const handlePointerDown = (event: MouseEvent) => {
if (
userMenuRef.current &&
!userMenuRef.current.contains(event.target as Node)
) {
setIsUserMenuOpen(false);
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setIsUserMenuOpen(false);
};
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isUserMenuOpen]);
const themeToggleButton = enableThemeToggle ? (
<button
type="button"
onClick={toggleTheme}
aria-label={
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</button>
) : null;
return (
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
<Sidebar
title={title}
items={sidebarItems}
activeHref={activeHref}
onNavigate={onNavigate}
headerExtra={themeToggleButton}
/>
<div className="flex flex-1 flex-col">
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
{title}
</div>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="Change language"
className={iconButtonClass}
>
<Languages className="h-5 w-5" />
</button>
<button
type="button"
aria-label="Notifications"
className={`${iconButtonClass} relative`}
>
<Bell className="h-5 w-5" />
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
</button>
<div ref={userMenuRef} className="relative ml-1">
<button
type="button"
aria-haspopup="menu"
aria-expanded={isUserMenuOpen}
onClick={() => setIsUserMenuOpen((open) => !open)}
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5 aria-expanded:border-[#33578D]/30 aria-expanded:bg-[#33578D]/10 dark:hover:border-[#33578D]/30 dark:hover:bg-[#33578D]/10 dark:aria-expanded:border-[#33578D]/40 dark:aria-expanded:bg-[#33578D]/20"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#33578D] text-xs font-semibold text-white">
JD
</div>
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
John Doe
</span>
<ChevronDown
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${
isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
}`}
/>
</button>
{isUserMenuOpen ? (
<div
role="menu"
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
John Doe
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
john.doe@edr.com
</p>
</div>
<a
href="#profile"
role="menuitem"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
>
<User className="h-4 w-4" />
Profile
</a>
<a
href="#logout"
role="menuitem"
onClick={() => setIsUserMenuOpen(false)}
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
>
<LogOut className="h-4 w-4" />
Logout
</a>
</div>
) : null}
</div>
{headerRight}
</div>
</header>
<main className="flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-950">
{children}
</main>
</div>
</div>
</div>
);
);
};
export default DashboardLayout;

View File

@@ -12,39 +12,70 @@ export interface SidebarProps {
items: SidebarItem[];
activeHref?: string;
onNavigate?: (href: string) => void;
headerExtra?: ReactNode;
}
const Sidebar = ({ title, items, activeHref, onNavigate }: SidebarProps) => (
<aside className="flex w-60 flex-col gap-1 border-r border-gray-200 bg-white px-3 py-4">
const Sidebar = ({
title,
items,
activeHref,
onNavigate,
headerExtra,
}: SidebarProps) => (
<aside className="flex w-64 flex-col gap-1 border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
{title ? (
<div className="px-2 pb-3 text-sm font-semibold text-gray-700">
{title}
<div className="flex items-center justify-between gap-2 px-3 pb-4">
<div className="flex items-center gap-2">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
{title.charAt(0)}
</div>
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
{title}
</div>
</div>
{headerExtra}
</div>
) : null}
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(event) => {
if (onNavigate) {
event.preventDefault();
onNavigate(item.href);
}
}}
className={clsx(
"flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors",
activeHref === item.href
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100",
)}
>
{item.icon ? (
<span className="text-gray-500">{item.icon}</span>
) : null}
<span>{item.label}</span>
</a>
))}
<nav className="flex flex-col gap-1">
{items.map((item) => {
const isActive =
activeHref?.toLowerCase() === item.href.toLowerCase();
return (
<a
key={item.href}
href={item.href}
onClick={(event) => {
if (onNavigate) {
event.preventDefault();
onNavigate(item.href);
}
}}
aria-current={isActive ? "page" : undefined}
className={clsx(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
isActive
? "bg-[#33578D] text-white shadow-sm shadow-[#33578D]/20"
: "text-slate-700 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white",
)}
>
{item.icon ? (
<span
className={clsx(
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
isActive
? "text-white"
: "text-slate-500 group-hover:text-[#33578D] dark:text-slate-400 dark:group-hover:text-white",
)}
>
{item.icon}
</span>
) : null}
<span>{item.label}</span>
</a>
);
})}
</nav>
</aside>
);

9
pnpm-lock.yaml generated
View File

@@ -226,12 +226,18 @@ importers:
axios:
specifier: ^1.7.7
version: 1.16.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^1.14.0
version: 1.14.0(react@18.3.1)
radix-ui:
specifier: ^1.4.3
version: 1.4.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react:
specifier: 18.3.1
version: 18.3.1
@@ -241,6 +247,9 @@ importers:
react-router-dom:
specifier: ^6.27.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
zustand:
specifier: ^5.0.0
version: 5.0.13(@types/react@18.3.28)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))