Merge pull request #6 from Tria-plc/feat/customer_registration

add dashboard and dropdown settings
This commit is contained in:
yaschalew10
2026-05-16 16:35:01 +03:00
committed by GitHub
18 changed files with 1931 additions and 18 deletions

View File

@@ -8,8 +8,8 @@
}
*::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 8px;
height: 10px;
}
*::-webkit-scrollbar-track {

View File

@@ -24,6 +24,7 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.0"
},

View File

@@ -15,6 +15,7 @@ import {
Train,
Receipt,
FileText,
Settings,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
@@ -31,6 +32,7 @@ import CustomersPage from "./pages/customers/CustomersPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
const sidebarItems: SidebarItem[] = [
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
@@ -41,6 +43,7 @@ const sidebarItems: SidebarItem[] = [
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
];
const App = () => {
@@ -78,6 +81,10 @@ const App = () => {
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route
path="/admin/dropdowns"
element={<DropdownSettingsPage />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
export interface DeleteDropdownSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteDropdownSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
}: DeleteDropdownSettingDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete dropdown setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its options. Forms referencing this code will fall back to
empty options.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,318 @@
import { useMemo, useState } from "react";
import {
Boxes,
CheckCircle2,
Filter,
ListOrdered,
Pencil,
Plus,
Search,
Settings,
Shield,
Sparkles,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import EditDropdownSettingDialog from "./EditDropdownSettingDialog";
import ManageDropdownOptionsDialog from "./ManageDropdownOptionsDialog";
import DeleteDropdownSettingDialog from "./DeleteDropdownSettingDialog";
import { dropdownSettings } from "./dropdownSettings.mock";
export default function DropdownSettingsPage() {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return dropdownSettings;
return dropdownSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q),
);
}, [query]);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + s.children.length,
0,
);
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
const searchableCount = dropdownSettings.filter(
(s) => s.meta?.searchable,
).length;
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage every dynamic dropdown across the platform labels,
options, ordering, and permissions.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search 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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
/>
</div>
<EditDropdownSettingDialog mode="create">
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-4 w-4" />
New Setting
</button>
</EditDropdownSettingDialog>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(dropdownSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Options"
value={String(totalOptions)}
icon={<Boxes className="h-5 w-5" />}
/>
<StatCard
title="Multi-select"
value={String(multipleCount)}
icon={<ListOrdered className="h-5 w-5" />}
/>
<StatCard
title="Searchable"
value={String(searchableCount)}
icon={<Sparkles className="h-5 w-5" />}
/>
</div>
{/* Table */}
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Registered Dropdowns
</h2>
<p className="text-sm text-slate-500">
Every dynamic dropdown the platform reads from.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Setting</th>
<th className="px-6 py-4 font-medium">Code</th>
<th className="px-6 py-4 font-medium">Options</th>
<th className="px-6 py-4 font-medium">Behavior</th>
<th className="px-6 py-4 font-medium">Permissions</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-6 py-12 text-center text-sm text-slate-500"
>
No dropdown settings match your search.
</td>
</tr>
) : (
filtered.map((setting) => (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
<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-[#33578D]" />
<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-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]"
>
<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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<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-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
>
<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>
);
}
function BehaviorChip({
label,
muted = false,
}: {
label: string;
muted?: boolean;
}) {
return (
<span
className={
muted
? "rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600"
: "rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]"
}
>
{label}
</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-[#33578D]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
import { useState, type ReactNode } from "react";
import { Hash } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import type { DropdownSetting } from "@/types/dropdownSettings";
export interface EditDropdownSettingDialogProps {
mode?: "create" | "edit";
setting?: DropdownSetting;
children: ReactNode;
}
export default function EditDropdownSettingDialog({
mode = "create",
setting,
children,
}: EditDropdownSettingDialogProps) {
const isEdit = mode === "edit";
const [multiple, setMultiple] = useState<boolean>(setting?.multiple ?? false);
const [searchable, setSearchable] = useState<boolean>(
setting?.meta?.searchable ?? false,
);
const [clearable, setClearable] = useState<boolean>(
setting?.meta?.clearable ?? false,
);
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
{isEdit ? "Edit Dropdown Setting" : "New Dropdown Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this dropdown setting."
: "Define a new dynamic dropdown that admins can manage."}
</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Code *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={setting?.code ?? ""}
placeholder="e.g. cargo_type"
className="pl-10 font-mono"
/>
</div>
<p className="text-xs text-slate-500">
Stable identifier used in code. Use snake_case.
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
defaultValue={setting?.label ?? ""}
placeholder="e.g. Cargo Type"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
defaultValue={setting?.description ?? ""}
placeholder="What this dropdown represents and where it's used..."
/>
</div>
<div className="space-y-2">
<Label>Icon (meta.icon)</Label>
<Input
defaultValue={setting?.meta?.icon ?? ""}
placeholder="lucide icon name, e.g. package"
/>
</div>
<div className="space-y-2">
<Label>Color (meta.color)</Label>
<Input
type="text"
defaultValue={setting?.meta?.color ?? ""}
placeholder="#33578D"
/>
</div>
<div className="space-y-2">
<Label>Permissions (comma-separated)</Label>
<Input
defaultValue={setting?.meta?.permissions?.join(", ") ?? ""}
placeholder="admin, ops"
/>
</div>
<div className="space-y-2">
<Label>Version (meta.version)</Label>
<Input
defaultValue={setting?.meta?.version ?? "1.0"}
placeholder="1.0"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Behavior</Label>
<div className="flex flex-wrap gap-3">
<ToggleChip
checked={multiple}
onChange={setMultiple}
label="Multi-select"
description="Users can pick more than one option"
/>
<ToggleChip
checked={searchable}
onChange={setSearchable}
label="Searchable"
description="Show a search input in the dropdown"
/>
<ToggleChip
checked={clearable}
onChange={setClearable}
label="Clearable"
description="Allow users to clear the selection"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
{isEdit ? "Save Changes" : "Create Setting"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function ToggleChip({
checked,
onChange,
label,
description,
}: {
checked: boolean;
onChange: (next: boolean) => void;
label: string;
description: string;
}) {
return (
<label
className={
checked
? "flex cursor-pointer items-start gap-2 rounded-2xl border border-[#33578D]/40 bg-[#33578D]/10 px-3 py-2 text-sm"
: "flex cursor-pointer items-start gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-sm transition hover:border-[#33578D]/30 hover:bg-[#33578D]/5"
}
>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
/>
<div>
<p className="font-medium text-slate-900">{label}</p>
<p className="text-xs text-slate-500">{description}</p>
</div>
</label>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import type {
DropdownOption,
DropdownSetting,
} from "@/types/dropdownSettings";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
children: ReactNode;
}
function makeEmptyOption(settingCode: string, idx: number): DropdownOption {
return {
id: `${settingCode}-new-${Date.now()}-${idx}`,
value: "",
label: "",
order: idx + 1,
};
}
export default function ManageDropdownOptionsDialog({
setting,
children,
}: ManageDropdownOptionsDialogProps) {
const sortedInitial = [...setting.children].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
const [options, setOptions] = useState<DropdownOption[]>(sortedInitial);
const update = (i: number, patch: Partial<DropdownOption>) =>
setOptions((prev) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
const updateMeta = (
i: number,
patch: Partial<NonNullable<DropdownOption["meta"]>>,
) =>
setOptions((prev) =>
prev.map((o, idx) =>
idx === i ? { ...o, meta: { ...(o.meta ?? {}), ...patch } } : o,
),
);
const remove = (i: number) =>
setOptions((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setOptions((prev) => [
...prev,
makeEmptyOption(setting.code, prev.length),
]);
const move = (i: number, dir: -1 | 1) =>
setOptions((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DropdownOption;
const b = next[target] as DropdownOption;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Options · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove options for{" "}
<span className="font-mono text-slate-700">{setting.code}</span>.
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{options.length} option{options.length === 1 ? "" : "s"}
</p>
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#33578D] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#33578D]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Option
</button>
</div>
{options.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No options yet. Click <span className="font-medium">Add Option</span> to start.
</div>
) : (
<div className="space-y-2">
{options.map((opt, i) => (
<div
key={opt.id}
className="grid gap-2 rounded-2xl border border-slate-200 bg-white p-3 md:grid-cols-[auto_1fr_1fr_1fr_auto_auto_auto]"
>
<div className="flex items-center gap-1 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => move(i, -1)}
aria-label="Move up"
disabled={i === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#33578D] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => move(i, 1)}
aria-label="Move down"
disabled={i === options.length - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#33578D] disabled:opacity-30"
>
</button>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Label *</Label>
<Input
value={opt.label}
onChange={(e) => update(i, { label: e.target.value })}
placeholder="Display label"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Value</Label>
<Input
value={opt.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="Stored value"
className="font-mono"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Note</Label>
<Input
value={opt.note ?? ""}
onChange={(e) => update(i, { note: e.target.value })}
placeholder="Helper text"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Badge</Label>
<Input
value={opt.meta?.badge ?? ""}
onChange={(e) => updateMeta(i, { badge: e.target.value })}
placeholder="—"
className="w-20"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Color</Label>
<Input
type="text"
value={opt.meta?.color ?? ""}
onChange={(e) => updateMeta(i, { color: e.target.value })}
placeholder="#…"
className="w-24 font-mono"
/>
</div>
<div className="flex flex-col items-center justify-between gap-2">
<label className="flex items-center gap-1 text-xs text-slate-600">
<input
type="checkbox"
checked={opt.disabled ?? false}
onChange={(e) =>
update(i, { disabled: e.target.checked })
}
className="h-3.5 w-3.5 rounded border-slate-300 text-[#33578D] focus:ring-[#33578D]/20"
/>
Off
</label>
<button
type="button"
onClick={() => remove(i)}
aria-label={`Remove ${opt.label || "option"}`}
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
Save Options
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,388 @@
import type {
DropdownOption,
DropdownSetting,
} from "@/types/dropdownSettings";
const opt = (
setting: string,
idx: number,
label: string,
extras: Partial<DropdownOption> = {},
): DropdownOption => ({
id: `${setting}-${idx + 1}`,
value: extras.value ?? label.toLowerCase().replace(/\s+/g, "_"),
label,
order: idx + 1,
...extras,
});
export const dropdownSettings: DropdownSetting[] = [
{
id: "ds-customer_type",
code: "customer_type",
label: "Customer Type",
description: "Used in the customer registration form.",
multiple: false,
meta: {
icon: "users",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("customer_type", 0, "Importer"),
opt("customer_type", 1, "Exporter"),
opt("customer_type", 2, "Supplier"),
],
},
{
id: "ds-cargo_type",
code: "cargo_type",
label: "Cargo Type",
description: "Cargo categories used in bookings and consignments.",
multiple: false,
meta: {
icon: "package",
searchable: true,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("cargo_type", 0, "Containerized"),
opt("cargo_type", 1, "Bulk"),
opt("cargo_type", 2, "Liquid"),
opt("cargo_type", 3, "Refrigerated", {
meta: { badge: "Cold chain", color: "#0ea5e9" },
}),
opt("cargo_type", 4, "Hazardous", {
meta: { badge: "DG", color: "#dc2626" },
}),
opt("cargo_type", 5, "General"),
],
},
{
id: "ds-container_type",
code: "container_type",
label: "Container Type",
description: "Container sizes available for freight bookings.",
multiple: false,
meta: {
icon: "package",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("container_type", 0, "20FT"),
opt("container_type", 1, "40FT"),
opt("container_type", 2, "40HC", { note: "High cube" }),
opt("container_type", 3, "Reefer", {
note: "Refrigerated",
meta: { color: "#0ea5e9" },
}),
],
},
{
id: "ds-transport_mode",
code: "transport_mode",
label: "Transport Mode",
description: "Modes of transport. Multimodal unlocks leg-by-leg editing.",
multiple: false,
meta: {
icon: "train",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("transport_mode", 0, "Rail"),
opt("transport_mode", 1, "Truck"),
opt("transport_mode", 2, "Multimodal", {
note: "Composes rail + truck legs",
}),
],
},
{
id: "ds-booking_priority",
code: "booking_priority",
label: "Booking Priority",
description: "Service-level priority applied to a booking.",
multiple: false,
meta: {
icon: "flag",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("booking_priority", 0, "Normal"),
opt("booking_priority", 1, "High", {
meta: { badge: "↑", color: "#d97706" },
}),
opt("booking_priority", 2, "Urgent", {
meta: { badge: "!", color: "#dc2626" },
}),
],
},
{
id: "ds-booking_status",
code: "booking_status",
label: "Booking Status",
description: "Lifecycle states for a freight booking.",
multiple: false,
meta: {
icon: "calendar-check",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("booking_status", 0, "Pending", { meta: { color: "#d97706" } }),
opt("booking_status", 1, "Confirmed", { meta: { color: "#0ea5e9" } }),
opt("booking_status", 2, "In Transit", { meta: { color: "#6366f1" } }),
opt("booking_status", 3, "Delivered", { meta: { color: "#059669" } }),
opt("booking_status", 4, "Cancelled", { meta: { color: "#dc2626" } }),
],
},
{
id: "ds-consignment_status",
code: "consignment_status",
label: "Consignment Status",
description: "Handling status for an individual consignment.",
multiple: false,
meta: {
icon: "package",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("consignment_status", 0, "Pending", { meta: { color: "#d97706" } }),
opt("consignment_status", 1, "In Warehouse", {
meta: { color: "#0ea5e9" },
}),
opt("consignment_status", 2, "In Transit", {
meta: { color: "#6366f1" },
}),
opt("consignment_status", 3, "Delivered", {
meta: { color: "#059669" },
}),
opt("consignment_status", 4, "Returned", { meta: { color: "#dc2626" } }),
],
},
{
id: "ds-shipment_status",
code: "shipment_status",
label: "Shipment Status",
description: "Tracking states for an in-flight shipment.",
multiple: false,
meta: {
icon: "truck",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("shipment_status", 0, "In Transit", { meta: { color: "#6366f1" } }),
opt("shipment_status", 1, "Delivered", { meta: { color: "#059669" } }),
opt("shipment_status", 2, "Delayed", { meta: { color: "#dc2626" } }),
],
},
{
id: "ds-shipment_mode",
code: "shipment_mode",
label: "Shipment Mode",
description: "Active transport mode for a shipment.",
multiple: false,
meta: {
icon: "train",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("shipment_mode", 0, "Rail", { value: "rail" }),
opt("shipment_mode", 1, "Truck", { value: "truck" }),
opt("shipment_mode", 2, "Multimodal", { value: "multimodal" }),
],
},
{
id: "ds-train_type",
code: "train_type",
label: "Train Type",
description: "Rolling stock categories.",
multiple: false,
meta: {
icon: "train",
searchable: false,
clearable: false,
permissions: ["admin", "fleet"],
version: "1.0",
},
children: [
opt("train_type", 0, "Locomotive"),
opt("train_type", 1, "Freight Wagon"),
opt("train_type", 2, "Tanker Wagon"),
opt("train_type", 3, "Container Wagon"),
opt("train_type", 4, "Reefer Wagon"),
],
},
{
id: "ds-train_status",
code: "train_status",
label: "Train Status",
description: "Operational status of rolling stock.",
multiple: false,
meta: {
icon: "wrench",
searchable: false,
clearable: false,
permissions: ["admin", "fleet"],
version: "1.0",
},
children: [
opt("train_status", 0, "Operational", { meta: { color: "#059669" } }),
opt("train_status", 1, "In Maintenance", {
meta: { color: "#d97706" },
}),
opt("train_status", 2, "Idle", { meta: { color: "#475569" } }),
opt("train_status", 3, "Out of Service", {
meta: { color: "#dc2626" },
}),
],
},
{
id: "ds-currency",
code: "currency",
label: "Currency",
description: "Currencies accepted on invoices.",
multiple: false,
meta: {
icon: "dollar-sign",
searchable: true,
clearable: false,
permissions: ["admin", "finance"],
version: "1.0",
},
children: [
opt("currency", 0, "USD", { meta: { badge: "$" } }),
opt("currency", 1, "ETB", { meta: { badge: "Br" } }),
opt("currency", 2, "DJF", { meta: { badge: "DJF" } }),
],
},
{
id: "ds-invoice_status",
code: "invoice_status",
label: "Invoice Status",
description: "Lifecycle of a billing invoice.",
multiple: false,
meta: {
icon: "receipt",
searchable: false,
clearable: false,
permissions: ["admin", "finance"],
version: "1.0",
},
children: [
opt("invoice_status", 0, "Draft", { meta: { color: "#475569" } }),
opt("invoice_status", 1, "Sent", { meta: { color: "#0ea5e9" } }),
opt("invoice_status", 2, "Paid", { meta: { color: "#059669" } }),
opt("invoice_status", 3, "Overdue", { meta: { color: "#dc2626" } }),
opt("invoice_status", 4, "Cancelled", { meta: { color: "#d97706" } }),
],
},
{
id: "ds-document_type",
code: "document_type",
label: "Document Type",
description: "Freight document categories.",
multiple: false,
meta: {
icon: "file-text",
searchable: true,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("document_type", 0, "Bill of Lading"),
opt("document_type", 1, "Commercial Invoice"),
opt("document_type", 2, "Packing List"),
opt("document_type", 3, "Customs Declaration"),
opt("document_type", 4, "Certificate of Origin"),
opt("document_type", 5, "Insurance Certificate"),
opt("document_type", 6, "Delivery Receipt"),
opt("document_type", 7, "Proof of Delivery"),
opt("document_type", 8, "Contract"),
opt("document_type", 9, "Other"),
],
},
{
id: "ds-document_status",
code: "document_status",
label: "Document Status",
description: "Review status of uploaded documents.",
multiple: false,
meta: {
icon: "file-text",
searchable: false,
clearable: false,
permissions: ["admin", "ops"],
version: "1.0",
},
children: [
opt("document_status", 0, "Draft", { meta: { color: "#475569" } }),
opt("document_status", 1, "Pending Review", {
meta: { color: "#d97706" },
}),
opt("document_status", 2, "Approved", { meta: { color: "#059669" } }),
opt("document_status", 3, "Rejected", { meta: { color: "#dc2626" } }),
opt("document_status", 4, "Expired", { meta: { color: "#94a3b8" } }),
],
},
{
id: "ds-permission",
code: "permission",
label: "Permission Tag",
description:
"Tags used to gate dropdowns (multi-select). Drives meta.permissions on other settings.",
multiple: true,
meta: {
icon: "shield",
searchable: true,
clearable: true,
permissions: ["admin"],
version: "1.0",
},
children: [
opt("permission", 0, "admin"),
opt("permission", 1, "ops"),
opt("permission", 2, "fleet"),
opt("permission", 3, "finance"),
opt("permission", 4, "viewer"),
],
},
];
export function getSettingByCode(
code: string,
): DropdownSetting | undefined {
return dropdownSettings.find((s) => s.code === code);
}
export function getOptionsByCode(code: string): DropdownOption[] {
const setting = getSettingByCode(code);
if (!setting) return [];
return [...setting.children].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
}

View File

@@ -200,7 +200,7 @@ export default function BillingPage() {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left">
<table className="w-full min-w-[900px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Invoice</th>

View File

@@ -116,7 +116,7 @@ export default function BookingsPage() {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[800px] text-left">
<table className="w-full min-w-[800px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Reference</th>

View File

@@ -203,7 +203,7 @@ export default function ConsignmentsPage() {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<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">Tracking #</th>

View File

@@ -114,7 +114,7 @@ export default function CustomerPage() {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[700px] text-left">
<table className="w-full min-w-[700px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Customer</th>

View File

@@ -1,10 +1,691 @@
const DashboardPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Freight Dashboard</h1>
<p className="text-sm text-gray-600">
Operational KPIs (bookings, consignments, on-time rate, revenue) go here.
</p>
</div>
);
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import {
ArrowDownRight,
ArrowRight,
ArrowUpRight,
CheckCircle2,
CircleDot,
DollarSign,
Package,
Truck,
Users,
} from "lucide-react";
export default DashboardPage;
import Breadcrumbs from "@/components/Breadcrumbs";
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
import { customers } from "../customers/customers.mock";
import { invoices } from "../billing/invoices.mock";
import { shipments } from "../tracking/shipments.mock";
import { trains } from "../trains/trains.mock";
const BRAND = "#33578D";
const BRAND_LIGHT = "#6B8AB8";
const BRAND_LIGHTER = "#B7C7DE";
const STATUS_PALETTE: Record<string, string> = {
Pending: "#d97706",
Confirmed: "#0ea5e9",
"In Transit": "#6366f1",
Delivered: "#059669",
Cancelled: "#dc2626",
};
export default function DashboardPage() {
/** Top KPI numbers. */
const totalRevenue = useMemo(
() =>
invoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0),
[],
);
const activeShipments = shipments.filter(
(s) => s.status === "In Transit",
).length;
const onTimeRate = Math.round(
(shipments.filter((s) => s.status !== "Delayed").length /
shipments.length) *
100,
);
/** Bookings per month (last 6 mock months). */
const bookingsTrend = useMemo(() => {
const months = ["Dec", "Jan", "Feb", "Mar", "Apr", "May"];
const total = bookings.length;
return months.map((label, i) => ({
month: label,
bookings: Math.round(total * (0.5 + i * 0.12) + i * 3),
delivered: Math.round(total * (0.3 + i * 0.1) + i * 2),
}));
}, []);
/** Revenue by currency. */
const revenueByCurrency = useMemo(() => {
const buckets = new Map<string, number>();
invoices.forEach((inv) => {
if (inv.status !== "Paid") return;
buckets.set(
inv.currency,
(buckets.get(inv.currency) ?? 0) + inv.amount,
);
});
return Array.from(buckets.entries()).map(([currency, value]) => ({
currency,
value: Math.round(value),
}));
}, []);
/** Booking status distribution (pie). */
const bookingStatusData = useMemo(() => {
const buckets = new Map<string, number>();
bookings.forEach((b) => {
buckets.set(b.status, (buckets.get(b.status) ?? 0) + 1);
});
return Array.from(buckets.entries()).map(([status, count]) => ({
name: status,
value: count,
color: STATUS_PALETTE[status] ?? BRAND,
}));
}, []);
/** Cargo type distribution (horizontal bar). */
const cargoTypeData = useMemo(() => {
const buckets = new Map<string, number>();
bookings.forEach((b) => {
buckets.set(b.cargoType, (buckets.get(b.cargoType) ?? 0) + 1);
});
return Array.from(buckets.entries())
.map(([cargo, count]) => ({ cargo, count }))
.sort((a, b) => b.count - a.count);
}, []);
/** Active corridor performance (multi-line). */
const corridorPerformance = useMemo(() => {
const weeks = ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"];
return weeks.map((label, i) => ({
week: label,
"Addis → Djibouti": 80 + Math.round(Math.sin(i / 2) * 8 + i * 1.2),
"Dire Dawa → Djibouti": 72 + Math.round(Math.cos(i / 2) * 6 + i * 0.8),
"Adama → Dire Dawa": 65 + Math.round(Math.sin(i / 3) * 10 + i * 1.4),
}));
}, []);
/** Fleet utilization. */
const fleetUtilization = useMemo(() => {
const total = trains.length;
return [
{
name: "Operational",
value: trains.filter((t) => t.status === "Operational").length,
color: "#059669",
},
{
name: "Idle",
value: trains.filter((t) => t.status === "Idle").length,
color: "#475569",
},
{
name: "Maintenance",
value: trains.filter((t) => t.status === "In Maintenance").length,
color: "#d97706",
},
{
name: "Out of Service",
value: trains.filter((t) => t.status === "Out of Service").length,
color: "#dc2626",
},
].filter((entry) => entry.value > 0);
}, []);
/** Recent shipments (top 5 by id). */
const recentShipments = useMemo(
() => [...shipments].slice(-5).reverse(),
[],
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Dashboard" }]} />
{/* Header */}
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Freight Dashboard
</h1>
<p className="mt-1 text-sm text-sm text-slate-500">
Operational overview · today · all corridors
</p>
</div>
<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-[#33578D] to-[#4a72ad] px-3 py-1 backdrop-blur">
Live
</span>
<span className="rounded-full cursor-pointer hover:scale-110 text-white bg-gradient-to-r from-[#33578D] to-[#4a72ad] px-3 py-1 backdrop-blur">
Last 30 days
</span>
</div>
</div>
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Total Revenue (USD)"
value={`$${totalRevenue.toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}`}
delta="+12.4%"
trend="up"
icon={<DollarSign className="h-5 w-5" />}
/>
<KpiCard
label="Active Bookings"
value={String(
bookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
).length,
)}
delta="+5.1%"
trend="up"
icon={<Package className="h-5 w-5" />}
/>
<KpiCard
label="Active Shipments"
value={String(activeShipments)}
delta="-2.3%"
trend="down"
icon={<Truck className="h-5 w-5" />}
/>
<KpiCard
label="On-time Rate"
value={`${onTimeRate}%`}
delta="+1.8%"
trend="up"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
{/* Trend + Cargo mix */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Bookings trend (area chart) */}
<ChartCard
title="Bookings vs Deliveries"
subtitle="Last 6 months"
className="lg:col-span-2"
>
<ResponsiveContainer width="100%" height={280}>
<AreaChart
data={bookingsTrend}
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
>
<defs>
<linearGradient id="brandFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={BRAND} stopOpacity={0.5} />
<stop offset="100%" stopColor={BRAND} stopOpacity={0} />
</linearGradient>
<linearGradient id="lightFill" x1="0" y1="0" x2="0" y2="1">
<stop
offset="0%"
stopColor={BRAND_LIGHT}
stopOpacity={0.4}
/>
<stop
offset="100%"
stopColor={BRAND_LIGHT}
stopOpacity={0}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="month"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "12px" }}
iconType="circle"
/>
<Area
type="monotone"
dataKey="bookings"
stroke={BRAND}
strokeWidth={2}
fill="url(#brandFill)"
name="Bookings"
/>
<Area
type="monotone"
dataKey="delivered"
stroke={BRAND_LIGHT}
strokeWidth={2}
fill="url(#lightFill)"
name="Delivered"
/>
</AreaChart>
</ResponsiveContainer>
</ChartCard>
{/* Booking status (donut) */}
<ChartCard title="Booking Status" subtitle="Current distribution">
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={bookingStatusData}
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={90}
paddingAngle={3}
dataKey="value"
>
{bookingStatusData.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Corridor + Cargo */}
<div className="grid gap-6 lg:grid-cols-3">
<ChartCard
title="Corridor On-time %"
subtitle="Weekly performance, top 3 corridors"
className="lg:col-span-2"
>
<ResponsiveContainer width="100%" height={280}>
<LineChart
data={corridorPerformance}
margin={{ top: 10, right: 10, left: -10, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="week"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis
stroke="#94a3b8"
style={{ fontSize: "12px" }}
domain={[0, 100]}
unit="%"
/>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "12px" }}
iconType="circle"
/>
<Line
type="monotone"
dataKey="Addis → Djibouti"
stroke={BRAND}
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="Dire Dawa → Djibouti"
stroke="#059669"
strokeWidth={2}
dot={{ r: 3 }}
/>
<Line
type="monotone"
dataKey="Adama → Dire Dawa"
stroke="#d97706"
strokeWidth={2}
dot={{ r: 3 }}
/>
</LineChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Cargo Mix" subtitle="Bookings by cargo type">
<ResponsiveContainer width="100%" height={280}>
<BarChart
data={cargoTypeData}
layout="vertical"
margin={{ top: 0, right: 20, left: 30, bottom: 0 }}
>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e2e8f0"
horizontal={false}
/>
<XAxis
type="number"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis
type="category"
dataKey="cargo"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
width={100}
/>
<Tooltip
cursor={{ fill: "#33578D14" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Bar
dataKey="count"
fill={BRAND}
radius={[0, 8, 8, 0]}
/>
</BarChart>
</ResponsiveContainer>
</ChartCard>
</div>
{/* Revenue + Fleet + Recent */}
<div className="grid gap-6 lg:grid-cols-3">
<ChartCard
title="Revenue by Currency"
subtitle="Paid invoices, all time"
>
<ResponsiveContainer width="100%" height={240}>
<BarChart
data={revenueByCurrency}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis
dataKey="currency"
stroke="#94a3b8"
style={{ fontSize: "12px" }}
/>
<YAxis stroke="#94a3b8" style={{ fontSize: "12px" }} />
<Tooltip
cursor={{ fill: "#33578D14" }}
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Bar dataKey="value" radius={[8, 8, 0, 0]}>
{revenueByCurrency.map((_, i) => (
<Cell
key={i}
fill={[BRAND, BRAND_LIGHT, BRAND_LIGHTER][i % 3]}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartCard>
<ChartCard title="Fleet Status" subtitle={`${trains.length} units`}>
<ResponsiveContainer width="100%" height={240}>
<PieChart>
<Pie
data={fleetUtilization}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={90}
paddingAngle={3}
dataKey="value"
>
{fleetUtilization.map((entry, i) => (
<Cell key={i} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: "white",
border: "1px solid #e2e8f0",
borderRadius: "12px",
fontSize: "12px",
}}
/>
<Legend
wrapperStyle={{ fontSize: "11px" }}
iconType="circle"
/>
</PieChart>
</ResponsiveContainer>
</ChartCard>
{/* Recent shipments */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Shipments
</h2>
<p className="text-xs text-slate-500">Latest activity</p>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-xs font-medium text-[#33578D] transition hover:underline"
>
View all
<ArrowRight className="h-3 w-3" />
</Link>
</div>
<ul className="space-y-3">
{recentShipments.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-slate-100 p-3 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5"
>
<div className="flex items-center gap-3 overflow-hidden">
<CircleDot
className="h-4 w-4 flex-none"
style={{
color:
s.status === "Delivered"
? "#059669"
: s.status === "Delayed"
? "#dc2626"
: "#6366f1",
}}
/>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900">
{s.reference}
</p>
<p className="truncate text-xs text-slate-500">
{s.originStation} {s.destinationStation}
</p>
</div>
</div>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
{s.progress}%
</span>
</li>
))}
</ul>
</div>
</div>
{/* Bottom summary cards */}
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard
label="Customers"
value={String(customers.length)}
href="/customers"
icon={<Users className="h-4 w-4" />}
/>
<SummaryCard
label="Consignments"
value={String(consignments.length)}
href="/consignments"
icon={<Package className="h-4 w-4" />}
/>
<SummaryCard
label="Trains in Fleet"
value={String(trains.length)}
href="/trains"
icon={<Truck className="h-4 w-4" />}
/>
<SummaryCard
label="Open Invoices"
value={String(
invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
).length,
)}
href="/billing"
icon={<DollarSign className="h-4 w-4" />}
/>
</div>
</div>
</div>
);
}
function KpiCard({
label,
value,
delta,
trend,
icon,
}: {
label: string;
value: string;
delta: string;
trend: "up" | "down";
icon: React.ReactNode;
}) {
const trendColor = trend === "up" ? "text-emerald-600" : "text-red-600";
const TrendIcon = trend === "up" ? ArrowUpRight : ArrowDownRight;
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
<div
className={`mt-2 inline-flex items-center gap-1 text-xs font-medium ${trendColor}`}
>
<TrendIcon className="h-3.5 w-3.5" />
{delta}
<span className="text-slate-400">vs last period</span>
</div>
</div>
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
</div>
</div>
);
}
function ChartCard({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`rounded-3xl bg-white p-6 shadow-sm ${className ?? ""}`}
>
<div className="mb-4">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
{subtitle ? (
<p className="text-xs text-slate-500">{subtitle}</p>
) : null}
</div>
{children}
</div>
);
}
function SummaryCard({
label,
value,
href,
icon,
}: {
label: string;
value: string;
href: string;
icon: React.ReactNode;
}) {
return (
<Link
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-[#33578D]/20"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D]/10 text-[#33578D]">
{icon}
</div>
<div>
<p className="text-xs text-slate-500">{label}</p>
<p className="text-lg font-bold text-slate-900">{value}</p>
</div>
</div>
<ArrowRight className="h-4 w-4 text-slate-400" />
</Link>
);
}

View File

@@ -387,7 +387,7 @@ function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<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>

View File

@@ -310,7 +310,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
return (
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[1000px] text-left">
<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">Shipment</th>

View File

@@ -186,7 +186,7 @@ export default function TrainsPage() {
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[900px] text-left">
<table className="w-full min-w-[900px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Train</th>

View File

@@ -0,0 +1,33 @@
export type DropdownOption = {
id: string;
value: string;
label: string;
note?: string;
disabled?: boolean;
order?: number;
meta?: {
icon?: string;
color?: string;
badge?: string;
};
};
export type DropdownSetting = {
id: string;
code: string;
label: string;
description?: string;
multiple?: boolean;
meta?: {
icon?: string;
color?: string;
searchable?: boolean;
clearable?: boolean;
permissions?: string[];
version?: string;
};
children: DropdownOption[];
};

3
pnpm-lock.yaml generated
View File

@@ -247,6 +247,9 @@ importers:
react-router-dom:
specifier: ^6.27.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.6)(react@18.3.1)(redux@5.0.1)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0