mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add dashboard and dropdown settings
This commit is contained in:
@@ -8,8 +8,8 @@
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
width: 8px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
apps/edr-freight-web/portal/src/types/dropdownSettings.ts
Normal file
33
apps/edr-freight-web/portal/src/types/dropdownSettings.ts
Normal 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[];
|
||||
};
|
||||
Reference in New Issue
Block a user