style: update the pages to use global theme

This commit is contained in:
ghost2023
2026-05-20 12:04:07 +03:00
parent e1aa01724f
commit 059e555dc9
13 changed files with 1897 additions and 2430 deletions

View File

@@ -18,8 +18,22 @@ import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog"; import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog"; import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import { dropdownSettings } from "./dropdownSettings.mock"; import { dropdownSettings } from "./dropdownSettings.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
export default function DropdownSettingsPage() { export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -33,6 +47,16 @@ export default function DropdownSettingsPage() {
); );
}, [query]); }, [query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce( const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + s.children.length, (sum, s) => sum + s.children.length,
0, 0,
@@ -42,9 +66,132 @@ export default function DropdownSettingsPage() {
(s) => s.meta?.searchable, (s) => s.meta?.searchable,
).length; ).length;
const columns: ColumnDef<(typeof dropdownSettings)[number]>[] = [
{
id: "setting",
header: "Setting",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
{s.description ?? "No description"}
</p>
</div>
</div>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children.length}</span>
</div>
);
},
},
{
id: "behavior",
header: "Behavior",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? (
<BehaviorChip label="Searchable" />
) : null}
{s.meta?.clearable ? (
<BehaviorChip label="Clearable" />
) : null}
</div>
);
},
},
{
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap items-center gap-1">
{(s.meta?.permissions ?? []).length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
(s.meta?.permissions ?? []).map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
);
},
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const setting = row.original;
return (
<div className="flex justify-end gap-2">
<ManageDropdownOptionsDialog setting={setting}>
<Button variant="outline" size="sm">
<CheckCircle2 />
Options
</Button>
</ManageDropdownOptionsDialog>
<EditDropdownSettingDialog mode="edit" setting={setting}>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</EditDropdownSettingDialog>
<DeleteDropdownSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteDropdownSettingDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Admin", href: "/admin" }, { label: "Admin", href: "/admin" },
@@ -52,13 +199,12 @@ export default function DropdownSettingsPage() {
]} ]}
/> />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings Dropdown Settings
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Manage every dynamic dropdown across the platform labels, Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions. options, ordering, and permissions.
</p> </p>
@@ -66,208 +212,122 @@ export default function DropdownSettingsPage() {
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => {
setQuery(e.target.value);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
placeholder="Search by code, label, description..." placeholder="Search by code, label, description..."
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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<EditDropdownSettingDialog mode="create"> <EditDropdownSettingDialog mode="create">
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Setting New Setting
</button> </Button>
</EditDropdownSettingDialog> </EditDropdownSettingDialog>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<StatCard <Card>
title="Settings" <CardContent className="flex items-center justify-between">
value={String(dropdownSettings.length)} <div>
icon={<Settings className="h-5 w-5" />} <p className="text-sm text-slate-500">Settings</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
<StatCard {dropdownSettings.length}
title="Total Options" </h3>
value={String(totalOptions)} </div>
icon={<Boxes className="h-5 w-5" />} <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
/> <Settings />
<StatCard </div>
title="Multi-select" </CardContent>
value={String(multipleCount)} </Card>
icon={<ListOrdered className="h-5 w-5" />}
/> <Card>
<StatCard <CardContent className="flex items-center justify-between">
title="Searchable" <div>
value={String(searchableCount)} <p className="text-sm text-slate-500">Total Options</p>
icon={<Sparkles className="h-5 w-5" />} <h3 className="mt-2 text-3xl font-bold text-slate-900">
/> {totalOptions}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Boxes />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Multi-select</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{multipleCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<ListOrdered />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Searchable</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{searchableCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Sparkles />
</div>
</CardContent>
</Card>
</div> </div>
{/* Table */} <Card className="gap-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <CardHeader className="flex flex-row items-center justify-between border-b">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Registered Dropdowns</CardTitle>
Registered Dropdowns <CardDescription>
</h2>
<p className="text-sm text-slate-500">
Every dynamic dropdown the platform reads from. Every dynamic dropdown the platform reads from.
</p> </CardDescription>
</div> </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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <Button variant="secondary" size="sm">
<Filter className="h-4 w-4" /> <Filter />
Filter Filter
</button> </Button>
</div> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="px-0">
<table className="w-full min-w-[1000px] whitespace-nowrap text-left"> <DataTable
<thead className="bg-slate-50 text-sm text-slate-500"> columns={columns}
<tr> data={paginatedData}
<th className="px-6 py-4 font-medium">Setting</th> status="success"
<th className="px-6 py-4 font-medium">Code</th> onRowClick={() => {}}
<th className="px-6 py-4 font-medium">Options</th> pagination={{
<th className="px-6 py-4 font-medium">Behavior</th> pageIndex: pagination.pageIndex,
<th className="px-6 py-4 font-medium">Permissions</th> pageSize: pagination.pageSize,
<th className="px-6 py-4 font-medium text-right">Actions</th> pageCount: pageCount,
</tr> totalCount: total,
</thead> }}
tableOptions={{
<tbody> state: { pagination },
{filtered.length === 0 ? ( onPaginationChange: setPagination,
<tr> }}
<td containerClassName="border-b shadow-none"
colSpan={6} footer={DataTableFooter}
className="px-6 py-12 text-center text-sm text-slate-500" />
> </CardContent>
No dropdown settings match your search. </Card>
</td>
</tr>
) : (
filtered.map((setting) => (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/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-[#10B981] text-white">
<Settings className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{setting.label}
</p>
<p className="text-xs text-slate-500">
{setting.description ?? "No description"}
</p>
</div>
</div>
</td>
<td className="px-6 py-4">
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{setting.code}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.children.length}
</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{setting.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{setting.meta?.searchable ? (
<BehaviorChip label="Searchable" />
) : null}
{setting.meta?.clearable ? (
<BehaviorChip label="Clearable" />
) : null}
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap items-center gap-1">
{(setting.meta?.permissions ?? []).length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
(setting.meta?.permissions ?? []).map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
>
<Shield className="h-3 w-3" />
{p}
</span>
))
)}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageDropdownOptionsDialog setting={setting}>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<CheckCircle2 className="h-3.5 w-3.5" />
Options
</button>
</ManageDropdownOptionsDialog>
<EditDropdownSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditDropdownSettingDialog>
<DeleteDropdownSettingDialog
settingLabel={setting.label}
settingCode={setting.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>
</DeleteDropdownSettingDialog>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -285,34 +345,10 @@ function BehaviorChip({
className={ className={
muted muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600" ? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]" : "rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
} }
> >
{label} {label}
</span> </span>
); );
} }
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-[#10B981]/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-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -1,8 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
AlertCircle, AlertCircle,
ChevronLeft,
ChevronRight,
Clock, Clock,
DollarSign, DollarSign,
Download, Download,
@@ -22,8 +20,19 @@ import {
invoices, invoices,
type InvoiceStatus, type InvoiceStatus,
} from "./invoices.mock"; } from "./invoices.mock";
import {
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
type FilterValue = "All" | InvoiceStatus; type FilterValue = "All" | InvoiceStatus;
@@ -37,8 +46,7 @@ const FILTERS: FilterValue[] = [
]; ];
export default function BillingPage() { export default function BillingPage() {
const [pageSize, setPageSize] = useState(10); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All"); const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -56,13 +64,13 @@ export default function BillingPage() {
}, [filter, query]); }, [filter, query]);
const total = filtered.length; const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.ceil(total / pagination.pageSize);
const safePage = Math.min(page, totalPages); const start = pagination.pageIndex * pagination.pageSize;
const start = (safePage - 1) * pageSize; const end = Math.min(start + pagination.pageSize, total);
const end = Math.min(start + pageSize, total);
const paginated = useMemo( const paginatedData = useMemo(
() => filtered.slice(start, end), () => filtered.slice(start, end),
[filtered, start, end], [start, end, filtered],
); );
const totalRevenue = invoices const totalRevenue = invoices
@@ -77,73 +85,179 @@ export default function BillingPage() {
.reduce((sum, inv) => sum + inv.amount, 0); .reduce((sum, inv) => sum + inv.amount, 0);
const overdueCount = invoices.filter((inv) => inv.status === "Overdue").length; const overdueCount = invoices.filter((inv) => inv.status === "Overdue").length;
const columns: ColumnDef<(typeof invoices)[number]>[] = [
{
id: "invoice",
header: "Invoice",
cell: ({ row }) => {
const inv = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Receipt />
</div>
<div>
<p className="font-medium text-slate-900">{inv.number}</p>
<p className="text-sm text-slate-500">Issued {inv.issueDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const inv = row.original;
return (
<span className="text-sm font-medium text-slate-900">
{formatCurrency(inv.amount, inv.currency)}
</span>
);
},
},
{
accessorKey: "dueDate",
header: "Due Date",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const invoice = row.original;
return (
<div className="flex justify-end gap-2">
<Button variant="outline" size="icon" aria-label="Download invoice">
<Download />
</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 variant="outline" size="icon">
<Pencil />
</Button>
</NewInvoicePage>
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteInvoiceDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Billing" }]} /> <Breadcrumbs items={[{ label: "Billing" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Billing Billing
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Manage invoices, payments, and financial records. Manage invoices, payments, and financial records.
</p> </p>
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
placeholder="Search invoices..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewInvoicePage> <NewInvoicePage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Invoice New Invoice
</button> </Button>
</NewInvoicePage> </NewInvoicePage>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<StatCard <Card>
title="Total Revenue (USD)" <CardContent className="flex items-center justify-between">
value={formatCurrency(totalRevenue, "USD")} <div>
icon={<DollarSign className="h-5 w-5" />} <p className="text-sm text-slate-500">Total Revenue (USD)</p>
tone="brand" <h3 className="mt-2 text-3xl font-bold text-slate-900">
/> {formatCurrency(totalRevenue, "USD")}
<StatCard </h3>
title="Outstanding (USD)" </div>
value={formatCurrency(outstanding, "USD")} <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
icon={<Clock className="h-5 w-5" />} <DollarSign />
tone="brand" </div>
/> </CardContent>
<StatCard </Card>
title="Overdue Invoices"
value={String(overdueCount)} <Card>
icon={<AlertCircle className="h-5 w-5" />} <CardContent className="flex items-center justify-between">
tone="danger" <div>
/> <p className="text-sm text-slate-500">Outstanding (USD)</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatCurrency(outstanding, "USD")}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Overdue Invoices</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{overdueCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertCircle />
</div>
</CardContent>
</Card>
</div> </div>
{/* Filter tabs */} <Card className="p-2">
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILTERS.map((f) => { {FILTERS.map((f) => {
const isActive = f === filter; const isActive = f === filter;
@@ -157,12 +271,12 @@ export default function BillingPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setFilter(f); setFilter(f);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
className={ className={
isActive isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white" ? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]" : "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
} }
> >
{f} {f}
@@ -179,276 +293,44 @@ export default function BillingPage() {
); );
})} })}
</div> </div>
</div> </Card>
{/* Invoices Table */} <Card className="gap-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <CardHeader className="flex flex-row items-center justify-between border-b">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Invoices</CardTitle>
Invoices <CardDescription>
</h2>
<p className="text-sm text-slate-500">
Issued invoices and their payment status. Issued invoices and their payment status.
</p> </CardDescription>
</div> </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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <Button variant="secondary" size="sm">
<Filter className="h-4 w-4" /> <Filter />
Filter Filter
</button> </Button>
</div> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="px-0">
<table className="w-full min-w-[900px] whitespace-nowrap text-left"> <DataTable
<thead className="bg-slate-50 text-sm text-slate-500"> columns={columns}
<tr> data={paginatedData}
<th className="px-6 py-4 font-medium">Invoice</th> status="success"
<th className="px-6 py-4 font-medium">Customer</th> onRowClick={() => {}}
<th className="px-6 py-4 font-medium">Booking</th> pagination={{
<th className="px-6 py-4 font-medium">Amount</th> pageIndex: pagination.pageIndex,
<th className="px-6 py-4 font-medium">Due Date</th> pageSize: pagination.pageSize,
<th className="px-6 py-4 font-medium">Status</th> pageCount: pageCount,
<th className="px-6 py-4 font-medium text-right">Actions</th> totalCount: total,
</tr> }}
</thead> tableOptions={{
state: { pagination },
<tbody> onPaginationChange: setPagination,
{paginated.length === 0 ? ( }}
<tr> containerClassName="border-b shadow-none"
<td footer={DataTableFooter}
colSpan={7} />
className="px-6 py-12 text-center text-sm text-slate-500" </CardContent>
> </Card>
No invoices match your filters.
</td>
</tr>
) : (
paginated.map((invoice) => (
<tr
key={invoice.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/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-[#10B981] text-white">
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] text-white";
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/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>
</div> </div>
); );

View File

@@ -17,6 +17,11 @@ import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "./NewBookingPage"; import NewBookingPage from "./NewBookingPage";
import DeleteBookingDialog from "./DeleteBookingDialog"; import DeleteBookingDialog from "./DeleteBookingDialog";
import { getBookingById, type BookingStatus } from "./bookings.mock"; import { getBookingById, type BookingStatus } from "./bookings.mock";
import {
Button,
Card,
CardContent,
} from "@edr/ui-common";
export default function BookingDetailPage() { export default function BookingDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -25,37 +30,37 @@ export default function BookingDetailPage() {
if (!booking) { if (!booking) {
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-5xl space-y-6"> <div className="space-y-6">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Bookings", href: "/bookings" }, { label: "Bookings", href: "/bookings" },
{ label: "Not found" }, { label: "Not found" },
]} ]}
/> />
<div className="rounded-3xl bg-white p-8 text-center shadow-sm"> <Card className="p-8 text-center">
<h1 className="text-2xl font-bold text-slate-900"> <h1 className="text-2xl font-bold text-slate-900">
Booking not found Booking not found
</h1> </h1>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-muted-foreground">
The booking you're looking for doesn't exist or has been removed. The booking you're looking for doesn't exist or has been removed.
</p> </p>
<Link <Link
to="/bookings" to="/bookings"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90" className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft />
Back to Bookings Back to Bookings
</Link> </Link>
</div> </Card>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-5xl space-y-6"> <div className="space-y-6">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Bookings", href: "/bookings" }, { label: "Bookings", href: "/bookings" },
@@ -63,18 +68,17 @@ export default function BookingDetailPage() {
]} ]}
/> />
{/* Header */} <Card className="p-6">
<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 flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white"> <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="h-8 w-8" /> <Package />
</div> </div>
<div> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
{booking.reference} {booking.reference}
</h1> </h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500"> <div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>{booking.customer}</span> <span>{booking.customer}</span>
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
<span>{booking.requestedDate}</span> <span>{booking.requestedDate}</span>
@@ -103,59 +107,49 @@ export default function BookingDetailPage() {
specialInstructions: booking.specialInstructions, specialInstructions: booking.specialInstructions,
}} }}
> >
<button <Button>Edit Booking</Button>
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Booking
</button>
</NewBookingPage> </NewBookingPage>
<DeleteBookingDialog <DeleteBookingDialog
bookingReference={booking.reference} bookingReference={booking.reference}
onConfirm={() => navigate("/bookings")} onConfirm={() => navigate("/bookings")}
> >
<button <Button variant="outline">
type="button" <Trash2 />
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 Cancel
</button> </Button>
</DeleteBookingDialog> </DeleteBookingDialog>
</div> </div>
</div> </div>
</div> </Card>
{/* Route banner */} <Card className="p-6">
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row"> <div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint <RouteEndpoint
label="Origin" label="Origin"
station={booking.originStation} station={booking.originStation}
icon={<MapPin className="h-5 w-5" />} icon={<MapPin />}
/> />
<div className="flex items-center gap-2 text-[#10B981]"> <div className="flex items-center gap-2 text-primary">
<Train className="h-5 w-5" /> <Train />
<ArrowRight className="h-5 w-5" /> <ArrowRight />
</div> </div>
<RouteEndpoint <RouteEndpoint
label="Destination" label="Destination"
station={booking.destinationStation} station={booking.destinationStation}
icon={<MapPin className="h-5 w-5" />} icon={<MapPin />}
/> />
</div> </div>
</div> </Card>
{/* Transport Legs (multimodal only) */}
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? ( {booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
<div className="rounded-3xl bg-white p-6 shadow-sm"> <Card className="p-6">
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<Train className="h-5 w-5 text-[#10B981]" /> <Train />
<h2 className="text-lg font-semibold text-slate-900"> <h2 className="text-lg font-semibold text-slate-900">
Transport Legs Transport Legs
</h2> </h2>
<span className="rounded-full bg-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"> <span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{booking.legs.length} legs {booking.legs.length} legs
</span> </span>
</div> </div>
@@ -163,10 +157,10 @@ export default function BookingDetailPage() {
{booking.legs.map((leg, i) => ( {booking.legs.map((leg, i) => (
<div <div
key={i} key={i}
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-[#10B981]/5 p-4 md:flex-row md:items-center md:justify-between" className="flex flex-col gap-3 rounded-2xl border bg-primary/5 p-4 md:flex-row md:items-center md:justify-between"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#10B981] text-sm font-bold text-white"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-sm font-bold text-primary-foreground">
{i + 1} {i + 1}
</div> </div>
<div> <div>
@@ -175,7 +169,7 @@ export default function BookingDetailPage() {
</p> </p>
<p className="font-semibold text-slate-900"> <p className="font-semibold text-slate-900">
{leg.from || "—"} {leg.from || "—"}
<ArrowRight className="mx-2 inline h-4 w-4 text-[#10B981]" /> <ArrowRight className="mx-2 inline text-primary" />
{leg.to || "—"} {leg.to || "—"}
</p> </p>
</div> </div>
@@ -183,29 +177,28 @@ export default function BookingDetailPage() {
</div> </div>
))} ))}
</div> </div>
</div> </Card>
) : null} ) : null}
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Customer & Cargo"> <DetailCard title="Customer & Cargo">
<DetailRow <DetailRow
icon={<User className="h-4 w-4" />} icon={<User />}
label="Customer" label="Customer"
value={booking.customer} value={booking.customer}
/> />
<DetailRow <DetailRow
icon={<Package className="h-4 w-4" />} icon={<Package />}
label="Cargo Type" label="Cargo Type"
value={booking.cargoType} value={booking.cargoType}
/> />
<DetailRow <DetailRow
icon={<Package className="h-4 w-4" />} icon={<Package />}
label="Container" label="Container"
value={`${booking.containerCount} × ${booking.containerType}`} value={`${booking.containerCount} × ${booking.containerType}`}
/> />
<DetailRow <DetailRow
icon={<Weight className="h-4 w-4" />} icon={<Weight />}
label="Weight" label="Weight"
value={`${booking.weightTons} tons`} value={`${booking.weightTons} tons`}
/> />
@@ -213,17 +206,17 @@ export default function BookingDetailPage() {
<DetailCard title="Schedule & Mode"> <DetailCard title="Schedule & Mode">
<DetailRow <DetailRow
icon={<Train className="h-4 w-4" />} icon={<Train />}
label="Transport Mode" label="Transport Mode"
value={booking.transportMode} value={booking.transportMode}
/> />
<DetailRow <DetailRow
icon={<Calendar className="h-4 w-4" />} icon={<Calendar />}
label="Requested Date" label="Requested Date"
value={booking.requestedDate} value={booking.requestedDate}
/> />
<DetailRow <DetailRow
icon={<Flag className="h-4 w-4" />} icon={<Flag />}
label="Priority" label="Priority"
value={booking.priority} value={booking.priority}
/> />
@@ -231,14 +224,14 @@ export default function BookingDetailPage() {
<DetailCard title="Cargo Description"> <DetailCard title="Cargo Description">
<div className="flex items-start gap-3 text-sm text-slate-700"> <div className="flex items-start gap-3 text-sm text-slate-700">
<Package className="mt-0.5 h-4 w-4 text-[#10B981]" /> <Package />
<p className="leading-relaxed">{booking.cargoDescription}</p> <p className="leading-relaxed">{booking.cargoDescription}</p>
</div> </div>
</DetailCard> </DetailCard>
<DetailCard title="Special Instructions"> <DetailCard title="Special Instructions">
<div className="flex items-start gap-3 text-sm text-slate-700"> <div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" /> <StickyNote />
<p className="leading-relaxed">{booking.specialInstructions}</p> <p className="leading-relaxed">{booking.specialInstructions}</p>
</div> </div>
</DetailCard> </DetailCard>
@@ -259,7 +252,7 @@ function RouteEndpoint({
}) { }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
{icon} {icon}
</div> </div>
<div> <div>
@@ -280,10 +273,10 @@ function DetailCard({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="rounded-3xl bg-white p-6 shadow-sm"> <Card className="p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div> <div className="mt-4 space-y-3">{children}</div>
</div> </Card>
); );
} }
@@ -298,7 +291,7 @@ function DetailRow({
}) { }) {
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div> <div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1"> <div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p> <p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p> <p className="mt-0.5 text-sm text-slate-900">{value}</p>

View File

@@ -1,9 +1,7 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { import {
ArrowRight, ArrowRight,
ChevronLeft,
ChevronRight,
Clock, Clock,
Eye, Eye,
Filter, Filter,
@@ -19,342 +17,248 @@ import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "./NewBookingPage"; import NewBookingPage from "./NewBookingPage";
import DeleteBookingDialog from "./DeleteBookingDialog"; import DeleteBookingDialog from "./DeleteBookingDialog";
import { bookings, type BookingStatus } from "./bookings.mock"; import { bookings, type BookingStatus } from "./bookings.mock";
import {
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
export default function BookingsPage() { export default function BookingsPage() {
const [pageSize, setPageSize] = useState(10); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [page, setPage] = useState(1);
const total = bookings.length; const total = bookings.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.ceil(total / pagination.pageSize);
const safePage = Math.min(page, totalPages); const start = pagination.pageIndex * pagination.pageSize;
const start = (safePage - 1) * pageSize; const end = Math.min(start + pagination.pageSize, total);
const end = Math.min(start + pageSize, total);
const paginated = useMemo( const paginatedData = useMemo(
() => bookings.slice(start, end), () => bookings.slice(start, end),
[start, end], [start, end],
); );
const columns: ColumnDef<(typeof bookings)[number]>[] = [
{
accessorKey: "reference",
header: "Reference",
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package />
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="text-sm text-slate-700">
<p>{b.cargoType}</p>
<p className="text-xs text-slate-500">
{b.containerCount} × {b.containerType} · {b.weightTons}t
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex justify-end gap-2">
<Link to={`/bookings/${booking.id}`}>
<Button variant="outline" size="icon">
<Eye />
</Button>
</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 variant="outline" size="icon">
<Pencil />
</Button>
</NewBookingPage>
<DeleteBookingDialog bookingReference={booking.reference}>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteBookingDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Bookings" }]} /> <Breadcrumbs items={[{ label: "Bookings" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Bookings Bookings
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Manage and monitor your freight bookings. Manage and monitor your freight bookings.
</p> </p>
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
placeholder="Search bookings..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewBookingPage> <NewBookingPage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Booking New Booking
</button> </Button>
</NewBookingPage> </NewBookingPage>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<StatCard <Card>
title="Total Bookings" <CardContent className="flex items-center justify-between">
value={String(total)} <div>
icon={<Package className="h-5 w-5" />} <p className="text-sm text-slate-500">Total Bookings</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
<StatCard {total}
title="In Transit" </h3>
value={String( </div>
bookings.filter((b) => b.status === "In Transit").length, <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
)} <Package />
icon={<Truck className="h-5 w-5" />} </div>
/> </CardContent>
<StatCard </Card>
title="Pending"
value={String( <Card>
bookings.filter((b) => b.status === "Pending").length, <CardContent className="flex items-center justify-between">
)} <div>
icon={<Clock className="h-5 w-5" />} <p className="text-sm text-slate-500">In Transit</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "In Transit").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "Pending").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div> </div>
{/* Booking Table */} <Card className="gap-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <CardHeader className="flex flex-row items-center justify-between border-b">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Booking List</CardTitle>
Booking List <CardDescription>
</h2>
<p className="text-sm text-slate-500">
Recent freight bookings and their status. Recent freight bookings and their status.
</p> </CardDescription>
</div> </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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <Button variant="secondary" size="sm">
<Filter className="h-4 w-4" /> <Filter />
Filter Filter
</button> </Button>
</div> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="px-0">
<table className="w-full min-w-[800px] whitespace-nowrap text-left"> <DataTable
<thead className="bg-slate-50 text-sm text-slate-500"> columns={columns}
<tr> data={paginatedData}
<th className="px-6 py-4 font-medium">Reference</th> status="success"
<th className="px-6 py-4 font-medium">Customer</th> onRowClick={() => {}}
<th className="px-6 py-4 font-medium">Route</th> pagination={{
<th className="px-6 py-4 font-medium">Cargo</th> pageIndex: pagination.pageIndex,
<th className="px-6 py-4 font-medium">Status</th> pageSize: pagination.pageSize,
<th className="px-6 py-4 font-medium text-right">Actions</th> pageCount: pageCount,
</tr> totalCount: total,
</thead> }}
tableOptions={{
<tbody> state: { pagination },
{paginated.map((booking) => ( onPaginationChange: setPagination,
<tr }}
key={booking.id} containerClassName="border-b shadow-none"
className="border-t border-slate-100 transition hover:bg-[#10B981]/5" footer={DataTableFooter}
> />
<td className="px-6 py-4"> </CardContent>
<div className="flex items-center gap-3"> </Card>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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>
</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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981]/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-[#10B981] text-white">
{icon}
</div>
</div> </div>
</div> </div>
); );
@@ -377,4 +281,3 @@ function StatusBadge({ status }: { status: BookingStatus }) {
</span> </span>
); );
} }

View File

@@ -21,6 +21,10 @@ import {
getConsignmentById, getConsignmentById,
type ConsignmentStatus, type ConsignmentStatus,
} from "./consignments.mock"; } from "./consignments.mock";
import {
Button,
Card,
} from "@edr/ui-common";
export default function ConsignmentDetailPage() { export default function ConsignmentDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@@ -29,38 +33,37 @@ export default function ConsignmentDetailPage() {
if (!consignment) { if (!consignment) {
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-5xl space-y-6"> <div className="space-y-6">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Consignments", href: "/consignments" }, { label: "Consignments", href: "/consignments" },
{ label: "Not found" }, { label: "Not found" },
]} ]}
/> />
<div className="rounded-3xl bg-white p-8 text-center shadow-sm"> <Card className="p-8 text-center">
<h1 className="text-2xl font-bold text-slate-900"> <h1 className="text-2xl font-bold text-slate-900">
Consignment not found Consignment not found
</h1> </h1>
<p className="mt-2 text-sm text-slate-500"> <p className="mt-2 text-sm text-muted-foreground">
The consignment you're looking for doesn't exist or has been The consignment you're looking for doesn't exist or has been removed.
removed.
</p> </p>
<Link <Link
to="/consignments" to="/consignments"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90" className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft />
Back to Consignments Back to Consignments
</Link> </Link>
</div> </Card>
</div> </div>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-5xl space-y-6"> <div className="space-y-6">
<Breadcrumbs <Breadcrumbs
items={[ items={[
{ label: "Consignments", href: "/consignments" }, { label: "Consignments", href: "/consignments" },
@@ -68,24 +71,23 @@ export default function ConsignmentDetailPage() {
]} ]}
/> />
{/* Header */} <Card className="p-6">
<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 flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white"> <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="h-8 w-8" /> <Package />
</div> </div>
<div> <div>
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900"> <h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900">
{consignment.trackingNumber} {consignment.trackingNumber}
{consignment.hazardous ? ( {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"> <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" /> <AlertTriangle />
Hazardous Hazardous
</span> </span>
) : null} ) : null}
</h1> </h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500"> <div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>{consignment.customer}</span> <span>{consignment.customer}</span>
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
<span>Booking {consignment.bookingReference}</span> <span>Booking {consignment.bookingReference}</span>
@@ -113,65 +115,55 @@ export default function ConsignmentDetailPage() {
estimatedDelivery: consignment.estimatedDelivery, estimatedDelivery: consignment.estimatedDelivery,
}} }}
> >
<button <Button>Edit Consignment</Button>
type="button"
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Consignment
</button>
</NewConsignmentPage> </NewConsignmentPage>
<DeleteConsignmentDialog <DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber} trackingNumber={consignment.trackingNumber}
onConfirm={() => navigate("/consignments")} onConfirm={() => navigate("/consignments")}
> >
<button <Button variant="outline">
type="button" <Trash2 />
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 Remove
</button> </Button>
</DeleteConsignmentDialog> </DeleteConsignmentDialog>
</div> </div>
</div> </div>
</div> </Card>
{/* Route banner */} <Card className="p-6">
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row"> <div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint <RouteEndpoint
label="Origin" label="Origin"
station={consignment.originStation} station={consignment.originStation}
/> />
<ArrowRight className="h-5 w-5 text-[#10B981]" /> <ArrowRight className="text-primary" />
<RouteEndpoint <RouteEndpoint
label="Destination" label="Destination"
station={consignment.destinationStation} station={consignment.destinationStation}
/> />
</div> </div>
</div> </Card>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Cargo"> <DetailCard title="Cargo">
<DetailRow <DetailRow
icon={<Package className="h-4 w-4" />} icon={<Package />}
label="Cargo Type" label="Cargo Type"
value={consignment.cargoType} value={consignment.cargoType}
/> />
<DetailRow <DetailRow
icon={<Hash className="h-4 w-4" />} icon={<Hash />}
label="Pieces" label="Pieces"
value={String(consignment.pieces)} value={String(consignment.pieces)}
/> />
<DetailRow <DetailRow
icon={<Weight className="h-4 w-4" />} icon={<Weight />}
label="Weight" label="Weight"
value={`${consignment.weightKg.toLocaleString()} kg`} value={`${consignment.weightKg.toLocaleString()} kg`}
/> />
<DetailRow <DetailRow
icon={<Ruler className="h-4 w-4" />} icon={<Ruler />}
label="Volume" label="Volume"
value={`${consignment.volumeM3}`} value={`${consignment.volumeM3}`}
/> />
@@ -179,22 +171,22 @@ export default function ConsignmentDetailPage() {
<DetailCard title="References & Schedule"> <DetailCard title="References & Schedule">
<DetailRow <DetailRow
icon={<Building2 className="h-4 w-4" />} icon={<Building2 />}
label="Customer" label="Customer"
value={consignment.customer} value={consignment.customer}
/> />
<DetailRow <DetailRow
icon={<Hash className="h-4 w-4" />} icon={<Hash />}
label="Booking" label="Booking"
value={consignment.bookingReference} value={consignment.bookingReference}
/> />
<DetailRow <DetailRow
icon={<Calendar className="h-4 w-4" />} icon={<Calendar />}
label="Created" label="Created"
value={consignment.createdAt} value={consignment.createdAt}
/> />
<DetailRow <DetailRow
icon={<Calendar className="h-4 w-4" />} icon={<Calendar />}
label="Estimated Delivery" label="Estimated Delivery"
value={consignment.estimatedDelivery} value={consignment.estimatedDelivery}
/> />
@@ -202,14 +194,14 @@ export default function ConsignmentDetailPage() {
<DetailCard title="Description"> <DetailCard title="Description">
<div className="flex items-start gap-3 text-sm text-slate-700"> <div className="flex items-start gap-3 text-sm text-slate-700">
<Package className="mt-0.5 h-4 w-4 text-[#10B981]" /> <Package />
<p className="leading-relaxed">{consignment.description}</p> <p className="leading-relaxed">{consignment.description}</p>
</div> </div>
</DetailCard> </DetailCard>
<DetailCard title="Special Handling"> <DetailCard title="Special Handling">
<div className="flex items-start gap-3 text-sm text-slate-700"> <div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" /> <StickyNote />
<p className="leading-relaxed">{consignment.specialHandling}</p> <p className="leading-relaxed">{consignment.specialHandling}</p>
</div> </div>
</DetailCard> </DetailCard>
@@ -228,8 +220,8 @@ function RouteEndpoint({
}) { }) {
return ( return (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white"> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<MapPin className="h-5 w-5" /> <MapPin />
</div> </div>
<div> <div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500"> <p className="text-xs font-medium uppercase tracking-wide text-slate-500">
@@ -249,10 +241,10 @@ function DetailCard({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="rounded-3xl bg-white p-6 shadow-sm"> <Card className="p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> <h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div> <div className="mt-4 space-y-3">{children}</div>
</div> </Card>
); );
} }
@@ -267,7 +259,7 @@ function DetailRow({
}) { }) {
return ( return (
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div> <div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1"> <div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p> <p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p> <p className="mt-0.5 text-sm text-slate-900">{value}</p>

View File

@@ -4,8 +4,6 @@ import {
AlertTriangle, AlertTriangle,
ArrowRight, ArrowRight,
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Eye, Eye,
Filter, Filter,
Package, Package,
@@ -23,8 +21,19 @@ import {
consignments, consignments,
type ConsignmentStatus, type ConsignmentStatus,
} from "./consignments.mock"; } from "./consignments.mock";
import {
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
type FilterValue = "All" | ConsignmentStatus; type FilterValue = "All" | ConsignmentStatus;
@@ -38,8 +47,7 @@ const FILTERS: FilterValue[] = [
]; ];
export default function ConsignmentsPage() { export default function ConsignmentsPage() {
const [pageSize, setPageSize] = useState(10); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All"); const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -60,13 +68,13 @@ export default function ConsignmentsPage() {
}, [filter, query]); }, [filter, query]);
const total = filtered.length; const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.ceil(total / pagination.pageSize);
const safePage = Math.min(page, totalPages); const start = pagination.pageIndex * pagination.pageSize;
const start = (safePage - 1) * pageSize; const end = Math.min(start + pagination.pageSize, total);
const end = Math.min(start + pageSize, total);
const paginated = useMemo( const paginatedData = useMemo(
() => filtered.slice(start, end), () => filtered.slice(start, end),
[filtered, start, end], [start, end, filtered],
); );
const inTransitCount = consignments.filter( const inTransitCount = consignments.filter(
@@ -77,76 +85,221 @@ export default function ConsignmentsPage() {
).length; ).length;
const hazmatCount = consignments.filter((c) => c.hazardous).length; const hazmatCount = consignments.filter((c) => c.hazardous).length;
const columns: ColumnDef<(typeof consignments)[number]>[] = [
{
id: "trackingNumber",
header: "Tracking #",
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package />
</div>
<div>
<p className="flex items-center gap-2 font-medium text-slate-900">
{c.trackingNumber}
{c.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 />
DG
</span>
) : null}
</p>
<p className="text-sm text-slate-500">{c.createdAt}</p>
</div>
</div>
);
},
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const c = row.original;
return (
<div className="text-sm text-slate-700">
<p>{c.cargoType}</p>
<p className="text-xs text-slate-500">
{c.pieces} pcs · {c.weightKg.toLocaleString()} kg · {c.volumeM3} m³
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const consignment = row.original;
return (
<div className="flex justify-end gap-2">
<Link to={`/consignments/${consignment.id}`}>
<Button variant="outline" size="icon">
<Eye />
</Button>
</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 variant="outline" size="icon">
<Pencil />
</Button>
</NewConsignmentPage>
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteConsignmentDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Consignments" }]} /> <Breadcrumbs items={[{ label: "Consignments" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Consignments Consignments
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Track cargo units and their handling status. Track cargo units and their handling status.
</p> </p>
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
placeholder="Search consignments..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewConsignmentPage> <NewConsignmentPage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Consignment New Consignment
</button> </Button>
</NewConsignmentPage> </NewConsignmentPage>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<StatCard <Card>
title="Total Consignments" <CardContent className="flex items-center justify-between">
value={String(consignments.length)} <div>
icon={<Package className="h-5 w-5" />} <p className="text-sm text-slate-500">Total Consignments</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
<StatCard {consignments.length}
title="In Transit" </h3>
value={String(inTransitCount)} </div>
icon={<Truck className="h-5 w-5" />} <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
/> <Package />
<StatCard </div>
title="Delivered" </CardContent>
value={String(deliveredCount)} </Card>
icon={<CheckCircle2 className="h-5 w-5" />}
/> <Card>
<StatCard <CardContent className="flex items-center justify-between">
title="Hazardous" <div>
value={String(hazmatCount)} <p className="text-sm text-slate-500">In Transit</p>
icon={<AlertTriangle className="h-5 w-5" />} <h3 className="mt-2 text-3xl font-bold text-slate-900">
tone="danger" {inTransitCount}
/> </h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Delivered</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{deliveredCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CheckCircle2 />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Hazardous</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{hazmatCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertTriangle />
</div>
</CardContent>
</Card>
</div> </div>
{/* Filter tabs */} <Card className="p-2">
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILTERS.map((f) => { {FILTERS.map((f) => {
const isActive = f === filter; const isActive = f === filter;
@@ -160,12 +313,12 @@ export default function ConsignmentsPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setFilter(f); setFilter(f);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
className={ className={
isActive isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white" ? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]" : "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
} }
> >
{f} {f}
@@ -182,303 +335,44 @@ export default function ConsignmentsPage() {
); );
})} })}
</div> </div>
</div> </Card>
{/* Table */} <Card className="gap-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <CardHeader className="flex flex-row items-center justify-between border-b">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Consignment List</CardTitle>
Consignment List <CardDescription>
</h2>
<p className="text-sm text-slate-500">
Cargo units and their current handling status. Cargo units and their current handling status.
</p> </CardDescription>
</div> </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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <Button variant="secondary" size="sm">
<Filter className="h-4 w-4" /> <Filter />
Filter Filter
</button> </Button>
</div> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="px-0">
<table className="w-full min-w-[1000px] whitespace-nowrap text-left"> <DataTable
<thead className="bg-slate-50 text-sm text-slate-500"> columns={columns}
<tr> data={paginatedData}
<th className="px-6 py-4 font-medium">Tracking #</th> status="success"
<th className="px-6 py-4 font-medium">Booking</th> onRowClick={() => {}}
<th className="px-6 py-4 font-medium">Customer</th> pagination={{
<th className="px-6 py-4 font-medium">Route</th> pageIndex: pagination.pageIndex,
<th className="px-6 py-4 font-medium">Cargo</th> pageSize: pagination.pageSize,
<th className="px-6 py-4 font-medium">Status</th> pageCount: pageCount,
<th className="px-6 py-4 font-medium text-right">Actions</th> totalCount: total,
</tr> }}
</thead> tableOptions={{
state: { pagination },
<tbody> onPaginationChange: setPagination,
{paginated.length === 0 ? ( }}
<tr> containerClassName="border-b shadow-none"
<td footer={DataTableFooter}
colSpan={7} />
className="px-6 py-12 text-center text-sm text-slate-500" </CardContent>
> </Card>
No consignments match your filters.
</td>
</tr>
) : (
paginated.map((consignment) => (
<tr
key={consignment.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/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-[#10B981] text-white">
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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>
);
}
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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] text-white";
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/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>
</div> </div>
); );

View File

@@ -90,11 +90,10 @@ export default function CustomerPage() {
const customer = row.original; const customer = row.original;
return ( return (
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link <Link to={`/customers/${customer.id}`}>
to={`/customers/${customer.id}`} <Button size="icon" variant="outline">
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 />
> </Button>
<Eye className="h-4 w-4" />
</Link> </Link>
<NewCustomerPage <NewCustomerPage
@@ -112,18 +111,15 @@ export default function CustomerPage() {
notes: customer.notes, notes: customer.notes,
}} }}
> >
<Button variant="outline"> <Button size="icon" variant="outline">
<Pencil /> <Pencil />
</Button> </Button>
</NewCustomerPage> </NewCustomerPage>
<DeleteCustomerDialog customerName={customer.name}> <DeleteCustomerDialog customerName={customer.name}>
<button <Button size="icon" type="button" variant="destructive">
type="button" <Trash2 />
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50" </Button>
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteCustomerDialog> </DeleteCustomerDialog>
</div> </div>
); );

View File

@@ -28,7 +28,7 @@ export default function DeleteCustomerDialog({
<Dialog> <Dialog>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl"> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-xl font-bold"> <DialogTitle className="text-xl font-bold">
Delete customer? Delete customer?

View File

@@ -7,12 +7,11 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; Input,
Label,
import { Input } from "@/components/ui/input"; Button,
import { Label } from "@/components/ui/label"; Textarea,
import { Button } from "@/components/ui/button"; } from "@edr/ui-common";
import { Textarea } from "@/components/ui/textarea";
import { import {
Building2, Building2,
@@ -61,7 +60,7 @@ export default function NewCustomerPage({
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>} {children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl"> <DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle> <DialogTitle className="text-2xl font-bold">{title}</DialogTitle>

View File

@@ -36,12 +36,17 @@ import { customers } from "../customers/customers.mock";
import { invoices } from "../billing/invoices.mock"; import { invoices } from "../billing/invoices.mock";
import { shipments } from "../tracking/shipments.mock"; import { shipments } from "../tracking/shipments.mock";
import { trains } from "../trains/trains.mock"; import { trains } from "../trains/trains.mock";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@edr/ui-common";
// Main brand color: #10B981 (emerald-500). Lighter variants for chart const BRAND = "#10B981";
// hierarchy so multi-series lines/areas/bars stay visually distinct. const BRAND_LIGHT = "#6EE7B7";
const BRAND = "#10B981"; // emerald-500 const BRAND_LIGHTER = "#D1FAE5";
const BRAND_LIGHT = "#6EE7B7"; // emerald-300
const BRAND_LIGHTER = "#D1FAE5"; // emerald-100
const STATUS_PALETTE: Record<string, string> = { const STATUS_PALETTE: Record<string, string> = {
Pending: "#d97706", Pending: "#d97706",
@@ -52,7 +57,6 @@ const STATUS_PALETTE: Record<string, string> = {
}; };
export default function DashboardPage() { export default function DashboardPage() {
/** Top KPI numbers. */
const totalRevenue = useMemo( const totalRevenue = useMemo(
() => () =>
invoices invoices
@@ -70,7 +74,6 @@ export default function DashboardPage() {
100, 100,
); );
/** Bookings per month (last 6 mock months). */
const bookingsTrend = useMemo(() => { const bookingsTrend = useMemo(() => {
const months = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"]; const months = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const total = bookings.length; const total = bookings.length;
@@ -81,7 +84,6 @@ export default function DashboardPage() {
})); }));
}, []); }, []);
/** Revenue by currency. */
const revenueByCurrency = useMemo(() => { const revenueByCurrency = useMemo(() => {
const buckets = new Map<string, number>(); const buckets = new Map<string, number>();
invoices.forEach((inv) => { invoices.forEach((inv) => {
@@ -97,7 +99,6 @@ export default function DashboardPage() {
})); }));
}, []); }, []);
/** Booking status distribution (pie). */
const bookingStatusData = useMemo(() => { const bookingStatusData = useMemo(() => {
const buckets = new Map<string, number>(); const buckets = new Map<string, number>();
bookings.forEach((b) => { bookings.forEach((b) => {
@@ -110,7 +111,6 @@ export default function DashboardPage() {
})); }));
}, []); }, []);
/** Cargo type distribution (horizontal bar). */
const cargoTypeData = useMemo(() => { const cargoTypeData = useMemo(() => {
const buckets = new Map<string, number>(); const buckets = new Map<string, number>();
bookings.forEach((b) => { bookings.forEach((b) => {
@@ -121,7 +121,6 @@ export default function DashboardPage() {
.sort((a, b) => b.count - a.count); .sort((a, b) => b.count - a.count);
}, []); }, []);
/** Active corridor performance (multi-line). */
const corridorPerformance = useMemo(() => { const corridorPerformance = useMemo(() => {
const weeks = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"]; const weeks = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
return weeks.map((label, i) => ({ return weeks.map((label, i) => ({
@@ -132,7 +131,6 @@ export default function DashboardPage() {
})); }));
}, []); }, []);
/** Fleet utilization. */
const fleetUtilization = useMemo(() => { const fleetUtilization = useMemo(() => {
const total = trains.length; const total = trains.length;
return [ return [
@@ -159,38 +157,35 @@ export default function DashboardPage() {
].filter((entry) => entry.value > 0); ].filter((entry) => entry.value > 0);
}, []); }, []);
/** Recent shipments (top 5 by id). */
const recentShipments = useMemo( const recentShipments = useMemo(
() => [...shipments].slice(-5).reverse(), () => [...shipments].slice(-5).reverse(),
[], [],
); );
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Dashboard" }]} /> <Breadcrumbs items={[{ label: "Dashboard" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Freight Dashboard Freight Dashboard
</h1> </h1>
<p className="mt-1 text-sm text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Operational overview · today · all corridors Operational overview · today · all corridors
</p> </p>
</div> </div>
<div className="flex flex-wrap items-center gap-2 text-xs"> <div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#059669] to-[#10B981] px-3 py-1 backdrop-blur"> <span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Live Live
</span> </span>
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#059669] to-[#10B981] px-3 py-1 backdrop-blur"> <span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Last 30 days Last 30 days
</span> </span>
</div> </div>
</div> </Card>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard <KpiCard
label="Total Revenue (USD)" label="Total Revenue (USD)"
@@ -200,7 +195,7 @@ export default function DashboardPage() {
})}`} })}`}
delta="+12.4%" delta="+12.4%"
trend="up" trend="up"
icon={<DollarSign className="h-5 w-5" />} icon={<DollarSign />}
/> />
<KpiCard <KpiCard
label="Active Bookings" label="Active Bookings"
@@ -211,27 +206,25 @@ export default function DashboardPage() {
)} )}
delta="+5.1%" delta="+5.1%"
trend="up" trend="up"
icon={<Package className="h-5 w-5" />} icon={<Package />}
/> />
<KpiCard <KpiCard
label="Active Shipments" label="Active Shipments"
value={String(activeShipments)} value={String(activeShipments)}
delta="-2.3%" delta="-2.3%"
trend="down" trend="down"
icon={<Truck className="h-5 w-5" />} icon={<Truck />}
/> />
<KpiCard <KpiCard
label="On-time Rate" label="On-time Rate"
value={`${onTimeRate}%`} value={`${onTimeRate}%`}
delta="+1.8%" delta="+1.8%"
trend="up" trend="up"
icon={<CheckCircle2 className="h-5 w-5" />} icon={<CheckCircle2 />}
/> />
</div> </div>
{/* Trend + Cargo mix */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Bookings trend (area chart) */}
<ChartCard <ChartCard
title="Bookings vs Deliveries" title="Bookings vs Deliveries"
subtitle="Last 6 months" subtitle="Last 6 months"
@@ -299,7 +292,6 @@ export default function DashboardPage() {
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>
{/* Booking status (donut) */}
<ChartCard title="Booking Status" subtitle="Current distribution"> <ChartCard title="Booking Status" subtitle="Current distribution">
<ResponsiveContainer width="100%" height={280}> <ResponsiveContainer width="100%" height={280}>
<PieChart> <PieChart>
@@ -333,7 +325,6 @@ export default function DashboardPage() {
</ChartCard> </ChartCard>
</div> </div>
{/* Corridor + Cargo */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<ChartCard <ChartCard
title="Corridor On-time %" title="Corridor On-time %"
@@ -437,7 +428,6 @@ export default function DashboardPage() {
</ChartCard> </ChartCard>
</div> </div>
{/* Revenue + Fleet + Recent */}
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
<ChartCard <ChartCard
title="Revenue by Currency" title="Revenue by Currency"
@@ -508,79 +498,76 @@ export default function DashboardPage() {
</ResponsiveContainer> </ResponsiveContainer>
</ChartCard> </ChartCard>
{/* Recent shipments */} <Card className="p-6">
<div className="rounded-3xl bg-white p-6 shadow-sm"> <CardHeader className="flex flex-row items-center justify-between px-0">
<div className="mb-4 flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Recent Shipments</CardTitle>
Recent Shipments <CardDescription>Latest activity</CardDescription>
</h2>
<p className="text-xs text-slate-500">Latest activity</p>
</div> </div>
<Link <Link
to="/tracking" to="/tracking"
className="inline-flex items-center gap-1 text-xs font-medium text-[#10B981] transition hover:underline" className="inline-flex items-center gap-1 text-xs font-medium text-primary transition hover:underline"
> >
View all View all
<ArrowRight className="h-3 w-3" /> <ArrowRight />
</Link> </Link>
</div> </CardHeader>
<ul className="space-y-3"> <CardContent className="px-0">
{recentShipments.map((s) => ( <ul className="space-y-3">
<li {recentShipments.map((s) => (
key={s.id} <li
className="flex items-center justify-between gap-3 rounded-2xl border border-slate-100 p-3 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5" key={s.id}
> className="flex items-center justify-between gap-3 rounded-2xl border p-3"
<div className="flex items-center gap-3 overflow-hidden"> >
<CircleDot <div className="flex items-center gap-3 overflow-hidden">
className="h-4 w-4 flex-none" <CircleDot
style={{ style={{
color: color:
s.status === "Delivered" s.status === "Delivered"
? "#059669" ? "#059669"
: s.status === "Delayed" : s.status === "Delayed"
? "#dc2626" ? "#dc2626"
: "#6366f1", : "#6366f1",
}} }}
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900"> <p className="truncate text-sm font-semibold text-slate-900">
{s.reference} {s.reference}
</p> </p>
<p className="truncate text-xs text-slate-500"> <p className="truncate text-xs text-slate-500">
{s.originStation} {s.destinationStation} {s.originStation} {s.destinationStation}
</p> </p>
</div>
</div> </div>
</div> <span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600"> {s.progress}%
{s.progress}% </span>
</span> </li>
</li> ))}
))} </ul>
</ul> </CardContent>
</div> </Card>
</div> </div>
{/* Bottom summary cards */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<SummaryCard <SummaryCard
label="Customers" label="Customers"
value={String(customers.length)} value={String(customers.length)}
href="/customers" href="/customers"
icon={<Users className="h-4 w-4" />} icon={<Users />}
/> />
<SummaryCard <SummaryCard
label="Consignments" label="Consignments"
value={String(consignments.length)} value={String(consignments.length)}
href="/consignments" href="/consignments"
icon={<Package className="h-4 w-4" />} icon={<Package />}
/> />
<SummaryCard <SummaryCard
label="Trains in Fleet" label="Trains in Fleet"
value={String(trains.length)} value={String(trains.length)}
href="/trains" href="/trains"
icon={<Truck className="h-4 w-4" />} icon={<Truck />}
/> />
<SummaryCard <SummaryCard
label="Open Invoices" label="Open Invoices"
@@ -590,7 +577,7 @@ export default function DashboardPage() {
).length, ).length,
)} )}
href="/billing" href="/billing"
icon={<DollarSign className="h-4 w-4" />} icon={<DollarSign />}
/> />
</div> </div>
</div> </div>
@@ -615,24 +602,24 @@ function KpiCard({
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight; const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
return ( return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20"> <Card>
<div className="flex items-start justify-between"> <CardContent className="flex items-start justify-between">
<div> <div>
<p className="text-sm text-slate-500">{label}</p> <p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3> <h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
<div <div
className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${trendColor}`} className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${trendColor}`}
> >
<TrendIcon className="h-3.5 w-3.5" /> <TrendIcon />
{delta} {delta}
<span className="text-slate-400">vs last period</span> <span className="text-slate-400">vs last period</span>
</div> </div>
</div> </div>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#10B981] text-white"> <div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
{icon} {icon}
</div> </div>
</div> </CardContent>
</div> </Card>
); );
} }
@@ -648,17 +635,13 @@ function ChartCard({
className?: string; className?: string;
}) { }) {
return ( return (
<div <Card className={className}>
className={`rounded-3xl bg-white p-6 shadow-sm ${className ?? ""}`} <CardHeader>
> <CardTitle>{title}</CardTitle>
<div className="mb-4"> {subtitle ? <CardDescription>{subtitle}</CardDescription> : null}
<h2 className="text-lg font-semibold text-slate-900">{title}</h2> </CardHeader>
{subtitle ? ( <CardContent>{children}</CardContent>
<p className="text-xs text-slate-500">{subtitle}</p> </Card>
) : null}
</div>
{children}
</div>
); );
} }
@@ -676,10 +659,10 @@ function SummaryCard({
return ( return (
<Link <Link
to={href} to={href}
className="flex items-center justify-between rounded-3xl bg-white p-4 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20" className="flex items-center justify-between rounded-xl border bg-card px-4 py-4 text-card-foreground shadow-xs transition hover:bg-accent"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#10B981] text-white"> <div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-primary-foreground">
{icon} {icon}
</div> </div>
<div> <div>
@@ -687,7 +670,7 @@ function SummaryCard({
<p className="text-lg font-bold text-slate-900">{value}</p> <p className="text-lg font-bold text-slate-900">{value}</p>
</div> </div>
</div> </div>
<ArrowRight className="h-4 w-4 text-slate-400" /> <ArrowRight className="text-slate-400" />
</Link> </Link>
); );
} }

View File

@@ -1,8 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
CheckCircle2, CheckCircle2,
ChevronLeft,
ChevronRight,
Clock, Clock,
Download, Download,
Eye, Eye,
@@ -30,8 +28,19 @@ import {
type DocumentRecord, type DocumentRecord,
type DocumentStatus, type DocumentStatus,
} from "./documents.mock"; } from "./documents.mock";
import {
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
type FilterValue = "All" | DocumentStatus; type FilterValue = "All" | DocumentStatus;
type ViewMode = "grid" | "table"; type ViewMode = "grid" | "table";
@@ -46,8 +55,7 @@ const FILTERS: FilterValue[] = [
]; ];
export default function DocumentsPage() { export default function DocumentsPage() {
const [pageSize, setPageSize] = useState(10); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All"); const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [view, setView] = useState<ViewMode>("table"); const [view, setView] = useState<ViewMode>("table");
@@ -67,13 +75,13 @@ export default function DocumentsPage() {
}, [filter, query]); }, [filter, query]);
const total = filtered.length; const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.ceil(total / pagination.pageSize);
const safePage = Math.min(page, totalPages); const start = pagination.pageIndex * pagination.pageSize;
const start = (safePage - 1) * pageSize; const end = Math.min(start + pagination.pageSize, total);
const end = Math.min(start + pageSize, total);
const paginated = useMemo( const paginatedData = useMemo(
() => filtered.slice(start, end), () => filtered.slice(start, end),
[filtered, start, end], [start, end, filtered],
); );
const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0); const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
@@ -82,18 +90,109 @@ export default function DocumentsPage() {
(d) => d.status === "Pending Review", (d) => d.status === "Pending Review",
).length; ).length;
const columns: ColumnDef<DocumentRecord>[] = [
{
id: "document",
header: "Document",
cell: ({ row }) => {
const doc = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/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>
);
},
},
{
accessorKey: "type",
header: "Type",
},
{
id: "linkedTo",
header: "Linked To",
cell: ({ row }) => (
<div className="text-sm text-slate-700">
<p>{row.original.linkedReference}</p>
<p className="text-xs text-slate-500">{row.original.linkedType}</p>
</div>
),
},
{
id: "size",
header: "Size",
cell: ({ row }) => (
<span className="text-sm text-slate-700">
{formatBytes(row.original.sizeBytes)}
</span>
),
},
{
accessorKey: "uploadedAt",
header: "Uploaded",
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const doc = row.original;
return (
<div className="flex justify-end gap-2">
<Button variant="outline" size="icon" aria-label="Preview">
<Eye />
</Button>
<Button variant="outline" size="icon" aria-label="Download">
<Download />
</Button>
<NewDocumentPage
mode="edit"
document={{
name: doc.name,
type: doc.type,
status: doc.status,
linkedType: doc.linkedType,
linkedReference: doc.linkedReference,
notes: doc.notes,
}}
>
<Button variant="outline" size="icon">
<Pencil />
</Button>
</NewDocumentPage>
<DeleteDocumentDialog documentName={doc.name}>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteDocumentDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Documents" }]} /> <Breadcrumbs items={[{ label: "Documents" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Documents Documents
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Manage freight documents linked to bookings, consignments, and Manage freight documents linked to bookings, consignments, and
invoices. invoices.
</p> </p>
@@ -101,147 +200,200 @@ export default function DocumentsPage() {
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setPage(1); setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}} }}
placeholder="Search documents..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewDocumentPage> <NewDocumentPage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
Upload Document Upload Document
</button> </Button>
</NewDocumentPage> </NewDocumentPage>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<StatCard <Card>
title="Total Documents" <CardContent className="flex items-center justify-between">
value={String(documents.length)} <div>
icon={<FileText className="h-5 w-5" />} <p className="text-sm text-slate-500">Total Documents</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
<StatCard {documents.length}
title="Approved" </h3>
value={String(approvedCount)} </div>
icon={<CheckCircle2 className="h-5 w-5" />} <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
/> <FileText />
<StatCard </div>
title="Pending Review" </CardContent>
value={String(pendingCount)} </Card>
icon={<Clock className="h-5 w-5" />}
/> <Card>
<StatCard <CardContent className="flex items-center justify-between">
title="Storage Used" <div>
value={formatBytes(totalSize)} <p className="text-sm text-slate-500">Approved</p>
icon={<HardDrive className="h-5 w-5" />} <h3 className="mt-2 text-3xl font-bold text-slate-900">
/> {approvedCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CheckCircle2 />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending Review</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{pendingCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Storage Used</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{formatBytes(totalSize)}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<HardDrive />
</div>
</CardContent>
</Card>
</div> </div>
{/* Filter tabs + view toggle */} <Card className="p-2">
<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-col gap-3 sm:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILTERS.map((f) => { {FILTERS.map((f) => {
const isActive = f === filter; const isActive = f === filter;
const count = const count =
f === "All" f === "All"
? documents.length ? documents.length
: documents.filter((d) => d.status === f).length; : documents.filter((d) => d.status === f).length;
return ( return (
<button <button
key={f} key={f}
type="button" type="button"
onClick={() => { onClick={() => {
setFilter(f); setFilter(f);
setPage(1); setPagination({
}} pageIndex: 0,
className={ pageSize: pagination.pageSize,
isActive });
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
<span
className={ className={
isActive isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs" ? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600" : "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
} }
> >
{count} {f}
</span> <span
</button> className={
); isActive
})} ? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
</div> : "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"> <div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
<ViewToggleButton <ViewToggleButton
active={view === "grid"} active={view === "grid"}
onClick={() => setView("grid")} onClick={() => setView("grid")}
label="Grid view" label="Grid view"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid className="size-4" />
<span className="hidden sm:inline">Grid</span> <span className="sm:inline">Grid</span>
</ViewToggleButton> </ViewToggleButton>
<ViewToggleButton <ViewToggleButton
active={view === "table"} active={view === "table"}
onClick={() => setView("table")} onClick={() => setView("table")}
label="Table view" label="Table view"
> >
<List className="h-4 w-4" /> <List className="size-4" />
<span className="hidden sm:inline">Table</span> <span className="sm:inline">Table</span>
</ViewToggleButton> </ViewToggleButton>
</div>
</div> </div>
</div> </Card>
{/* Empty / Grid / Table */} {paginatedData.length === 0 ? (
{paginated.length === 0 ? ( <Card className="p-12 text-center">
<div className="rounded-3xl bg-white p-12 text-center shadow-sm"> <p className="text-sm text-muted-foreground">
<p className="text-sm text-slate-500">
No documents match your filters. No documents match your filters.
</p> </p>
</div> </Card>
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{paginated.map((doc) => ( {paginatedData.map((doc) => (
<DocumentCard key={doc.id} doc={doc} /> <DocumentCard key={doc.id} doc={doc} />
))} ))}
</div> </div>
) : ( ) : (
<DocumentTable documents={paginated} /> <Card className="gap-0">
)} <CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Document Library</CardTitle>
<CardDescription>
All freight documents stored in the system.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
{filtered.length > 0 ? ( <CardContent className="px-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <DataTable
<Pagination columns={columns}
page={safePage} data={paginatedData}
pageSize={pageSize} status="success"
total={total} onRowClick={() => { }}
totalPages={totalPages} pagination={{
start={start} pageIndex: pagination.pageIndex,
end={end} pageSize: pagination.pageSize,
onPageChange={setPage} pageCount: pageCount,
onPageSizeChange={(size) => { totalCount: total,
setPageSize(size); }}
setPage(1); tableOptions={{
}} state: { pagination },
/> onPaginationChange: setPagination,
</div> }}
) : null} containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
)}
</div> </div>
</div> </div>
); );
@@ -266,8 +418,8 @@ function ViewToggleButton({
aria-pressed={active} aria-pressed={active}
className={ className={
active active
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#10B981] shadow-sm" ? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#10B981]" : "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
} }
> >
{children} {children}
@@ -276,85 +428,74 @@ function ViewToggleButton({
} }
function FormatIcon({ format }: { format: DocumentFormat }) { function FormatIcon({ format }: { format: DocumentFormat }) {
const className = "h-5 w-5 text-[#10B981]"; if (format === "PDF") return <FileText />;
if (format === "PDF") return <FileText className={className} />; if (format === "DOCX") return <FileText />;
if (format === "DOCX") return <FileText className={className} />; if (format === "XLSX") return <FileSpreadsheet />;
if (format === "XLSX") return <FileSpreadsheet className={className} />; if (format === "PNG" || format === "JPG") return <FileImage />;
if (format === "PNG" || format === "JPG") return <File />;
return <FileImage className={className} />;
return <File className={className} />;
} }
function DocumentCard({ doc }: { doc: DocumentRecord }) { function DocumentCard({ doc }: { doc: DocumentRecord }) {
return ( 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-[#10B981]/20"> <Card className="p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex flex-col gap-4">
<div className="flex items-start gap-3"> <div className="flex items-start justify-between gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981]/10"> <div className="flex items-start gap-3">
<FormatIcon format={doc.format} /> <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
</div> <FormatIcon format={doc.format} />
<div className="min-w-0 flex-1"> </div>
<p className="truncate text-sm font-semibold text-slate-900"> <div className="min-w-0 flex-1">
{doc.name} <p className="truncate text-sm font-semibold text-slate-900">
</p> {doc.name}
<p className="text-xs text-slate-500">{doc.type}</p> </p>
<p className="text-xs text-slate-500">{doc.type}</p>
</div>
</div> </div>
<StatusBadge status={doc.status} />
</div> </div>
<StatusBadge status={doc.status} />
</div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="grid grid-cols-2 gap-3 text-sm">
<MetaRow label="Linked to" value={`${doc.linkedType} · ${doc.linkedReference}`} /> <MetaRow
<MetaRow label="Format" value={doc.format} /> label="Linked to"
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} /> value={`${doc.linkedType} · ${doc.linkedReference}`}
<MetaRow label="Uploaded" value={doc.uploadedAt} /> />
</div> <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> <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"> <div className="flex items-center justify-end gap-2 border-t pt-3">
<button <Button variant="outline" size="icon" aria-label="Preview">
type="button" <Eye />
aria-label="Preview" </Button>
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]" <Button variant="outline" size="icon" aria-label="Download">
> <Download />
<Eye className="h-4 w-4" /> </Button>
</button> <NewDocumentPage
<button mode="edit"
type="button" document={{
aria-label="Download" name: doc.name,
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]" type: doc.type,
> status: doc.status,
<Download className="h-4 w-4" /> linkedType: doc.linkedType,
</button> linkedReference: doc.linkedReference,
<NewDocumentPage notes: doc.notes,
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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
> >
<Pencil className="h-4 w-4" /> <Button variant="outline" size="icon">
</button> <Pencil />
</NewDocumentPage> </Button>
<DeleteDocumentDialog documentName={doc.name}> </NewDocumentPage>
<button <DeleteDocumentDialog documentName={doc.name}>
type="button" <Button variant="outline" size="icon">
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50" <Trash2 />
> </Button>
<Trash2 className="h-4 w-4" /> </DeleteDocumentDialog>
</button> </div>
</DeleteDocumentDialog>
</div> </div>
</div> </Card>
); );
} }
@@ -367,250 +508,6 @@ function MetaRow({ label, value }: { label: string; value: string }) {
); );
} }
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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] whitespace-nowrap 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-[#10B981]/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-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981]/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-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: DocumentStatus }) { function StatusBadge({ status }: { status: DocumentStatus }) {
const styles: Record<DocumentStatus, string> = { const styles: Record<DocumentStatus, string> = {
Draft: "bg-slate-100 text-slate-600", Draft: "bg-slate-100 text-slate-600",

View File

@@ -23,6 +23,15 @@ import {
type ShipmentMode, type ShipmentMode,
type ShipmentStatus, type ShipmentStatus,
} from "./shipments.mock"; } from "./shipments.mock";
import {
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
type FilterValue = "All" | ShipmentStatus; type FilterValue = "All" | ShipmentStatus;
type ViewMode = "grid" | "table"; type ViewMode = "grid" | "table";
@@ -52,107 +61,103 @@ export default function TrackingPage() {
}, [filter, query]); }, [filter, query]);
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Tracking" }]} /> <Breadcrumbs items={[{ label: "Tracking" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Shipment Tracking Shipment Tracking
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Real-time cargo and shipment monitoring. Real-time cargo and shipment monitoring.
</p> </p>
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search shipments..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewShipmentPage> <NewShipmentPage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Shipment New Shipment
</button> </Button>
</NewShipmentPage> </NewShipmentPage>
</div> </div>
</div> </Card>
{/* Filter tabs + view toggle */} <Card className="p-2">
<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-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILTERS.map((f) => { {FILTERS.map((f) => {
const isActive = f === filter; const isActive = f === filter;
const count = const count =
f === "All" f === "All"
? shipments.length ? shipments.length
: shipments.filter((s) => s.status === f).length; : shipments.filter((s) => s.status === f).length;
return ( return (
<button <button
key={f} key={f}
type="button" type="button"
onClick={() => setFilter(f)} onClick={() => setFilter(f)}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] 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-[#10B981]/10 hover:text-[#10B981]"
}
>
{f}
<span
className={ className={
isActive isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs" ? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600" : "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
} }
> >
{count} {f}
</span> <span
</button> className={
); isActive
})} ? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
</div> : "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"> <div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
<ViewToggleButton <ViewToggleButton
active={view === "grid"} active={view === "grid"}
onClick={() => setView("grid")} onClick={() => setView("grid")}
label="Grid view" label="Grid view"
> >
<LayoutGrid className="h-4 w-4" /> <LayoutGrid />
<span className="hidden sm:inline">Grid</span> <span className="hidden sm:inline">Grid</span>
</ViewToggleButton> </ViewToggleButton>
<ViewToggleButton <ViewToggleButton
active={view === "table"} active={view === "table"}
onClick={() => setView("table")} onClick={() => setView("table")}
label="Table view" label="Table view"
> >
<List className="h-4 w-4" /> <List />
<span className="hidden sm:inline">Table</span> <span className="hidden sm:inline">Table</span>
</ViewToggleButton> </ViewToggleButton>
</div>
</div> </div>
</div> </Card>
{/* Empty / Grid / Table */}
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="rounded-3xl bg-white p-12 text-center shadow-sm"> <Card className="p-12 text-center">
<p className="text-sm text-slate-500"> <p className="text-sm text-muted-foreground">
No shipments match your filters. No shipments match your filters.
</p> </p>
</div> </Card>
) : view === "grid" ? ( ) : view === "grid" ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{filtered.map((shipment) => ( {filtered.map((shipment) => (
@@ -186,8 +191,8 @@ function ViewToggleButton({
aria-pressed={active} aria-pressed={active}
className={ className={
active active
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#10B981] shadow-sm" ? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-primary shadow-sm"
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#10B981]" : "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-primary"
} }
> >
{children} {children}
@@ -197,253 +202,246 @@ function ViewToggleButton({
function ShipmentCard({ shipment }: { shipment: Shipment }) { function ShipmentCard({ shipment }: { shipment: Shipment }) {
return ( 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-[#10B981]/20"> <Card className="p-5">
<div className="flex items-center justify-between"> <div className="flex flex-col gap-4">
<div className="flex flex-col gap-0.5"> <div className="flex items-center justify-between">
<span className="text-base font-bold text-slate-900"> <div className="flex flex-col gap-0.5">
{shipment.reference} <span className="text-base font-bold text-slate-900">
</span> {shipment.reference}
<span className="text-xs text-slate-500"> </span>
Consignment {shipment.consignmentReference} <span className="text-xs text-slate-500">
</span> Consignment {shipment.consignmentReference}
</span>
</div>
<StatusBadge status={shipment.status} />
</div> </div>
<StatusBadge status={shipment.status} />
</div>
{/* Route */} <div className="flex items-center justify-between rounded-2xl bg-primary/5 p-3">
<div className="flex items-center justify-between rounded-2xl bg-[#10B981]/5 p-3"> <div className="text-sm">
<div className="text-sm"> <p className="text-xs font-medium uppercase tracking-wide text-slate-500">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500"> From
From </p>
</p> <p className="font-semibold text-slate-900">
<p className="font-semibold text-slate-900"> {shipment.originStation}
{shipment.originStation} </p>
</p> </div>
<ArrowRight className="text-primary" />
<div className="text-right text-sm">
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
To
</p>
<p className="font-semibold text-slate-900">
{shipment.destinationStation}
</p>
</div>
</div> </div>
<ArrowRight className="h-5 w-5 text-[#10B981]" />
<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="space-y-1.5"> <div className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center justify-between text-xs text-slate-500"> <span>Progress</span>
<span>Progress</span> <span className="font-medium text-slate-700">
<span className="font-medium text-slate-700"> {shipment.progress}%
{shipment.progress}% </span>
</span> </div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div> </div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-[#10B981] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
{/* Meta */} <div className="space-y-2 text-sm text-slate-700">
<div className="space-y-2 text-sm text-slate-700"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Building2 />
<Building2 className="h-4 w-4 text-[#10B981]" /> <span>{shipment.customer}</span>
<span>{shipment.customer}</span> </div>
<div className="flex items-center gap-2">
<ModeIcon mode={shipment.mode} />
<span className="capitalize">{shipment.mode}</span>
</div>
<div className="flex items-center gap-2">
<MapPin />
<span>{shipment.currentLocation}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<Clock />
<span>
Updated {shipment.lastUpdate} · ETA {shipment.eta}
</span>
</div>
</div> </div>
<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-[#10B981]" />
<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 pt-3">
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-3"> <NewShipmentPage
<NewShipmentPage mode="edit"
mode="edit" shipment={{
shipment={{ bookingId: shipment.bookingId,
bookingId: shipment.bookingId, bookingReference: shipment.bookingReference,
bookingReference: shipment.bookingReference, originStation: shipment.originStation,
originStation: shipment.originStation, destinationStation: shipment.destinationStation,
destinationStation: shipment.destinationStation, mode: shipment.mode,
mode: shipment.mode, status: shipment.status,
status: shipment.status, currentLocation: shipment.currentLocation,
currentLocation: shipment.currentLocation, eta: shipment.eta,
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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
> >
<Pencil className="h-3.5 w-3.5" /> <Button variant="outline" size="sm">
Edit <Pencil />
</button> Edit
</NewShipmentPage> </Button>
</NewShipmentPage>
<DeleteShipmentDialog shipmentReference={shipment.reference}> <DeleteShipmentDialog shipmentReference={shipment.reference}>
<button <Button variant="outline" size="sm">
type="button" <Trash2 />
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" Remove
> </Button>
<Trash2 className="h-3.5 w-3.5" /> </DeleteShipmentDialog>
Remove </div>
</button>
</DeleteShipmentDialog>
</div> </div>
</div> </Card>
); );
} }
function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) { function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
return ( return (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <Card className="gap-0">
<div className="overflow-x-auto"> <CardHeader className="flex flex-row items-center justify-between border-b">
<table className="w-full min-w-[1000px] whitespace-nowrap text-left"> <div>
<thead className="bg-slate-50 text-sm text-slate-500"> <CardTitle>Shipment List</CardTitle>
<tr> <CardDescription>All shipments and their current status.</CardDescription>
<th className="px-6 py-4 font-medium">Shipment</th> </div>
<th className="px-6 py-4 font-medium">Booking</th> </CardHeader>
<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> <CardContent className="px-0">
{rows.map((shipment) => ( <div className="overflow-x-auto">
<tr <table className="w-full min-w-[1000px] whitespace-nowrap text-left">
key={shipment.id} <thead className="bg-muted text-sm text-muted-foreground">
className="border-t border-slate-100 transition hover:bg-[#10B981]/5" <tr>
> <th className="px-6 py-4 font-medium">Shipment</th>
<td className="px-6 py-4"> <th className="px-6 py-4 font-medium">Booking</th>
<div className="flex items-center gap-3"> <th className="px-6 py-4 font-medium">Route</th>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white"> <th className="px-6 py-4 font-medium">Mode</th>
<Truck className="h-5 w-5" /> <th className="px-6 py-4 font-medium">Status</th>
</div> <th className="px-6 py-4 font-medium">Progress</th>
<div> <th className="px-6 py-4 font-medium">Current Location</th>
<p className="font-medium text-slate-900"> <th className="px-6 py-4 font-medium">ETA</th>
{shipment.reference} <th className="px-6 py-4 font-medium text-right">Actions</th>
</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-[#10B981]"
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-[#10B981]" />
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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> </tr>
))} </thead>
</tbody>
</table> <tbody>
</div> {rows.map((shipment) => (
</div> <tr
key={shipment.id}
className="border-t transition hover:bg-muted/50"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Truck />
</div>
<div>
<p className="font-medium text-slate-900">
{shipment.reference}
</p>
<p className="text-sm text-slate-500">
{shipment.customer}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{shipment.bookingReference}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-2">
<span>{shipment.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{shipment.destinationStation}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm capitalize text-slate-700">
<ModeIcon mode={shipment.mode} />
{shipment.mode}
</div>
</td>
<td className="px-6 py-4">
<StatusBadge status={shipment.status} />
</td>
<td className="px-6 py-4">
<div className="flex w-32 items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-primary"
style={{ width: `${shipment.progress}%` }}
/>
</div>
<span className="text-xs font-medium text-slate-600">
{shipment.progress}%
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<MapPin />
{shipment.currentLocation}
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{shipment.eta}
</td>
<td className="px-6 py-4">
<div 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 variant="outline" size="icon">
<Pencil />
</Button>
</NewShipmentPage>
<DeleteShipmentDialog shipmentReference={shipment.reference}>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteShipmentDialog>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
); );
} }
function ModeIcon({ mode }: { mode: ShipmentMode }) { function ModeIcon({ mode }: { mode: ShipmentMode }) {
if (mode === "rail") return <Train className="h-4 w-4 text-[#10B981]" />; if (mode === "rail") return <Train />;
if (mode === "truck") return <Truck className="h-4 w-4 text-[#10B981]" />; if (mode === "truck") return <Truck />;
return <Train className="h-4 w-4 text-[#10B981]" />; return <Train />;
} }
function StatusBadge({ status }: { status: ShipmentStatus }) { function StatusBadge({ status }: { status: ShipmentStatus }) {

View File

@@ -1,7 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { import {
ChevronLeft,
ChevronRight,
Filter, Filter,
Gauge, Gauge,
Pencil, Pencil,
@@ -16,8 +14,19 @@ import Breadcrumbs from "@/components/Breadcrumbs";
import NewTrainPage from "./NewTrainPage"; import NewTrainPage from "./NewTrainPage";
import DeleteTrainDialog from "./DeleteTrainDialog"; import DeleteTrainDialog from "./DeleteTrainDialog";
import { trains, type TrainStatus } from "./trains.mock"; import { trains, type TrainStatus } from "./trains.mock";
import {
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50]; DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
} from "@edr/ui-common";
type FilterValue = "All" | TrainStatus; type FilterValue = "All" | TrainStatus;
@@ -30,8 +39,7 @@ const FILTERS: FilterValue[] = [
]; ];
export default function TrainsPage() { export default function TrainsPage() {
const [pageSize, setPageSize] = useState(10); const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [page, setPage] = useState(1);
const [filter, setFilter] = useState<FilterValue>("All"); const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -50,13 +58,13 @@ export default function TrainsPage() {
}, [filter, query]); }, [filter, query]);
const total = filtered.length; const total = filtered.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const pageCount = Math.ceil(total / pagination.pageSize);
const safePage = Math.min(page, totalPages); const start = pagination.pageIndex * pagination.pageSize;
const start = (safePage - 1) * pageSize; const end = Math.min(start + pagination.pageSize, total);
const end = Math.min(start + pageSize, total);
const paginated = useMemo( const paginatedData = useMemo(
() => filtered.slice(start, end), () => filtered.slice(start, end),
[filtered, start, end], [start, end, filtered],
); );
const operationalCount = trains.filter( const operationalCount = trains.filter(
@@ -66,70 +74,174 @@ export default function TrainsPage() {
(t) => t.status === "In Maintenance", (t) => t.status === "In Maintenance",
).length; ).length;
const columns: ColumnDef<(typeof trains)[number]>[] = [
{
id: "train",
header: "Train",
cell: ({ row }) => {
const t = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<TrainIcon />
</div>
<div>
<p className="font-medium text-slate-900">{t.code}</p>
<p className="text-sm text-slate-500">{t.name}</p>
</div>
</div>
);
},
},
{
accessorKey: "type",
header: "Type",
},
{
accessorKey: "capacityTons",
header: "Capacity",
cell: ({ row }) => <span>{row.original.capacityTons}t</span>,
},
{
accessorKey: "depot",
header: "Depot",
},
{
accessorKey: "mileageKm",
header: "Mileage",
cell: ({ row }) => (
<span>{row.original.mileageKm.toLocaleString()} km</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const train = row.original;
return (
<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 variant="outline" size="icon">
<Pencil />
</Button>
</NewTrainPage>
<DeleteTrainDialog trainCode={train.code}>
<Button variant="outline" size="icon">
<Trash2 />
</Button>
</DeleteTrainDialog>
</div>
);
},
},
];
return ( return (
<div className="min-h-screen bg-slate-50 p-6"> <div className="min-h-screen p-6">
<div className="mx-auto max-w-7xl space-y-6"> <div className="space-y-6">
<Breadcrumbs items={[{ label: "Trains" }]} /> <Breadcrumbs items={[{ label: "Trains" }]} />
{/* Header */} <Card className="p-6 flex-row justify-between">
<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> <div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900"> <h1 className="text-3xl font-bold tracking-tight text-slate-900">
Trains Trains
</h1> </h1>
<p className="mt-1 text-sm text-slate-500"> <p className="mt-1 text-sm text-secondary-foreground">
Fleet roster, capacity, and maintenance status. Fleet roster, capacity, and maintenance status.
</p> </p>
</div> </div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center"> <div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80"> <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" /> <Search className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-slate-400" />
<input <Input
type="search" type="search"
value={query} value={query}
onChange={(e) => { onChange={(e) => {
setQuery(e.target.value); setQuery(e.target.value);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
placeholder="Search trains..." 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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20" className="pl-8!"
/> />
</div> </div>
<NewTrainPage> <NewTrainPage>
<button <Button>
type="button" <Plus />
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Train New Train
</button> </Button>
</NewTrainPage> </NewTrainPage>
</div> </div>
</div> </Card>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3"> <div className="grid gap-4 md:grid-cols-3">
<StatCard <Card>
title="Total Trains" <CardContent className="flex items-center justify-between">
value={String(trains.length)} <div>
icon={<TrainIcon className="h-5 w-5" />} <p className="text-sm text-slate-500">Total Trains</p>
/> <h3 className="mt-2 text-3xl font-bold text-slate-900">
<StatCard {trains.length}
title="Operational" </h3>
value={String(operationalCount)} </div>
icon={<Gauge className="h-5 w-5" />} <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
/> <TrainIcon />
<StatCard </div>
title="In Maintenance" </CardContent>
value={String(maintenanceCount)} </Card>
icon={<Wrench className="h-5 w-5" />}
/> <Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Operational</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{operationalCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Gauge />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Maintenance</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{maintenanceCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Wrench />
</div>
</CardContent>
</Card>
</div> </div>
{/* Filter tabs */} <Card className="p-2">
<div className="rounded-3xl bg-white p-2 shadow-sm">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{FILTERS.map((f) => { {FILTERS.map((f) => {
const isActive = f === filter; const isActive = f === filter;
@@ -143,12 +255,12 @@ export default function TrainsPage() {
type="button" type="button"
onClick={() => { onClick={() => {
setFilter(f); setFilter(f);
setPage(1); setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}} }}
className={ className={
isActive isActive
? "inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white" ? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#10B981]/10 hover:text-[#10B981]" : "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
} }
> >
{f} {f}
@@ -165,262 +277,44 @@ export default function TrainsPage() {
); );
})} })}
</div> </div>
</div> </Card>
{/* Train Table */} <Card className="gap-0">
<div className="overflow-hidden rounded-3xl bg-white shadow-sm"> <CardHeader className="flex flex-row items-center justify-between border-b">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div> <div>
<h2 className="text-lg font-semibold text-slate-900"> <CardTitle>Fleet Roster</CardTitle>
Fleet Roster <CardDescription>
</h2>
<p className="text-sm text-slate-500">
All locomotives and wagons in service. All locomotives and wagons in service.
</p> </CardDescription>
</div> </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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"> <Button variant="secondary" size="sm">
<Filter className="h-4 w-4" /> <Filter />
Filter Filter
</button> </Button>
</div> </CardHeader>
<div className="overflow-x-auto"> <CardContent className="px-0">
<table className="w-full min-w-[900px] whitespace-nowrap text-left"> <DataTable
<thead className="bg-slate-50 text-sm text-slate-500"> columns={columns}
<tr> data={paginatedData}
<th className="px-6 py-4 font-medium">Train</th> status="success"
<th className="px-6 py-4 font-medium">Type</th> onRowClick={() => {}}
<th className="px-6 py-4 font-medium">Capacity</th> pagination={{
<th className="px-6 py-4 font-medium">Depot</th> pageIndex: pagination.pageIndex,
<th className="px-6 py-4 font-medium">Mileage</th> pageSize: pagination.pageSize,
<th className="px-6 py-4 font-medium">Status</th> pageCount: pageCount,
<th className="px-6 py-4 font-medium text-right">Actions</th> totalCount: total,
</tr> }}
</thead> tableOptions={{
state: { pagination },
<tbody> onPaginationChange: setPagination,
{paginated.length === 0 ? ( }}
<tr> containerClassName="border-b shadow-none"
<td footer={DataTableFooter}
colSpan={7} />
className="px-6 py-12 text-center text-sm text-slate-500" </CardContent>
> </Card>
No trains match your filters.
</td>
</tr>
) : (
paginated.map((train) => (
<tr
key={train.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/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-[#10B981] text-white">
<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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<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-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981] 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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
}
>
{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-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981] 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-[#10B981]/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-[#10B981] text-white">
{icon}
</div>
</div> </div>
</div> </div>
); );