mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 13:05:44 +00:00
style: update the pages to use global theme
This commit is contained in:
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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} m³`}
|
value={`${consignment.volumeM3} m³`}
|
||||||
/>
|
/>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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?
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user