mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
add dashboard and dropdown settings
This commit is contained in:
@@ -8,8 +8,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar {
|
*::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
height: 6px;
|
height: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
*::-webkit-scrollbar-track {
|
*::-webkit-scrollbar-track {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
Train,
|
Train,
|
||||||
Receipt,
|
Receipt,
|
||||||
FileText,
|
FileText,
|
||||||
|
Settings,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
@@ -31,6 +32,7 @@ import CustomersPage from "./pages/customers/CustomersPage";
|
|||||||
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
||||||
import DocumentsPage from "./pages/documents/DocumentsPage";
|
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||||
|
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
||||||
@@ -41,6 +43,7 @@ const sidebarItems: SidebarItem[] = [
|
|||||||
{ label: "Trains", href: "/trains", icon: <Train /> },
|
{ label: "Trains", href: "/trains", icon: <Train /> },
|
||||||
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
|
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -78,6 +81,10 @@ const App = () => {
|
|||||||
<Route path="/trains" element={<TrainsPage />} />
|
<Route path="/trains" element={<TrainsPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
<Route path="/documents" element={<DocumentsPage />} />
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
|
<Route
|
||||||
|
path="/admin/dropdowns"
|
||||||
|
element={<DropdownSettingsPage />}
|
||||||
|
/>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</DashboardLayout>
|
</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>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Invoice</th>
|
<th className="px-6 py-4 font-medium">Invoice</th>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export default function BookingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Reference</th>
|
<th className="px-6 py-4 font-medium">Reference</th>
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export default function ConsignmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Tracking #</th>
|
<th className="px-6 py-4 font-medium">Tracking #</th>
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export default function CustomerPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Customer</th>
|
<th className="px-6 py-4 font-medium">Customer</th>
|
||||||
|
|||||||
@@ -1,10 +1,691 @@
|
|||||||
const DashboardPage = () => (
|
import { useMemo } from "react";
|
||||||
<div className="flex flex-col gap-4">
|
import { Link } from "react-router-dom";
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Freight Dashboard</h1>
|
import {
|
||||||
<p className="text-sm text-gray-600">
|
Area,
|
||||||
Operational KPIs (bookings, consignments, on-time rate, revenue) go here.
|
AreaChart,
|
||||||
</p>
|
Bar,
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Document</th>
|
<th className="px-6 py-4 font-medium">Document</th>
|
||||||
|
|||||||
@@ -310,7 +310,7 @@ function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Shipment</th>
|
<th className="px-6 py-4 font-medium">Shipment</th>
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export default function TrainsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<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">
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-4 font-medium">Train</th>
|
<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[];
|
||||||
|
};
|
||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -247,6 +247,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
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:
|
tailwind-merge:
|
||||||
specifier: ^3.6.0
|
specifier: ^3.6.0
|
||||||
version: 3.6.0
|
version: 3.6.0
|
||||||
|
|||||||
Reference in New Issue
Block a user