Merge branch 'freight/develop' of https://github.com/Tria-plc/edr-platform into freight/rule_engine_ui

This commit is contained in:
hagiye
2026-05-28 17:01:10 +03:00
43 changed files with 487 additions and 9435 deletions

View File

@@ -7,65 +7,37 @@ import {
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import {
LayoutDashboard,
Users,
CalendarCheck,
Package,
MapPin,
Train,
Receipt,
FileText,
Settings,
UserCircle,
FileUp,
MapPinned,
Home,
Loader2,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
import MyBookings from "./pages/bookings/MyBookings";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import TrainsPage from "./pages/trains/TrainsPage";
import DashboardPage from "./pages/dashboard/DashboardPage";
import useAuth from "./hooks/useAuth";
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";
import FileUploadSettingsPage from "./pages/admin/FileUploadSettingsPage";
import MyPortalPage from "./pages/portal/MyPortalPage";
import MyPortalPage from "./pages/MyPortalPage";
import EDRFreightLandingPage from "./pages/EDRFreightLandingPage";
import SignupPage from "./pages/accounts/SignupPage";
import OnboardingPage from "./pages/accounts/OnboardingPage";
import VerificationOtpPage from "./pages/accounts/VerificationOtpPage";
import SetPasswordPage from "./pages/accounts/SetPasswordPage";
import LoginPage from "./pages/accounts/LoginPage";
import Station from "./components/stations/Station";
import MyBookings from "./pages/bookings/MyBookings";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import NewBookingPage from "./pages/bookings/NewBookingPage";
import TrackingPage from "./pages/tracking/TrackingPage";
import BillingPage from "./pages/billing/BillingPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import { useEffect } from "react";
const sidebarItems: SidebarItem[] = [
{ label: "My Portal", href: "/", icon: <UserCircle /> },
{ label: "Dashboard", href: "/dashboard", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Home", href: "/", icon: <Home /> },
{ label: "My Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Stations", href: "/stations", icon: <MapPinned /> },
{ 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 /> },
{
label: "File Upload Settings",
href: "/admin/file-uploads",
icon: <FileUp />,
},
];
const App = () => {
@@ -95,7 +67,7 @@ const App = () => {
<Route path="/signup" element={<SignupPage />} />
<Route path="/otp" element={<VerificationOtpPage />} />
<Route path="/set-password" element={<SetPasswordPage />} />
<Route path="*" element={<Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
@@ -119,28 +91,13 @@ const App = () => {
onLogout={logout}
>
<Routes>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/" element={<MyPortalPage />} />
<Route path="/bookings" element={<MyBookings />} />
<Route path="/admin/bookings" element={<BookingsPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/stations" element={<Station />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route path="/admin/dropdowns" element={<DropdownSettingsPage />} />
<Route
path="/admin/file-uploads"
element={<FileUploadSettingsPage />}
/>
<Route path="/user-management" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -1,228 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
CircleOff,
Loader2,
MapPin,
Search,
TrainFront,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import type { DropdownOption } from "@/types/dropdownSettings";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
DataTable,
DataTableFooter,
Input,
type ColumnDef,
usePagination,
} from "@edr/ui-common";
const STATION_DROPDOWN_CODE = "stations_ter";
export default function Station() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useDropdownSettingByCode(
STATION_DROPDOWN_CODE,
);
const stations = useMemo<DropdownOption[]>(
() => [...(data?.children ?? [])].sort((a, b) => a.order - b.order),
[data?.children],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return stations;
return stations.filter(
(station) =>
station.label.toLowerCase().includes(q) ||
station.value.toLowerCase().includes(q) ||
(station.note ?? "").toLowerCase().includes(q),
);
}, [query, stations]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[end, filtered, start],
);
const activeCount = stations.filter((station) => !station.disabled).length;
const disabledCount = stations.length - activeCount;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownOption>[] = [
{
id: "station",
header: "Station",
cell: ({ row }) => {
const station = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<MapPin />
</div>
<div>
<p className="font-medium text-slate-900">{station.label}</p>
<p className="text-xs text-slate-500">
{station.note ?? "No station note"}
</p>
</div>
</div>
);
},
},
{
id: "value",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.value}
</span>
),
},
{
accessorKey: "order",
header: "Order",
},
{
id: "status",
header: "Status",
cell: ({ row }) =>
row.original.disabled ? (
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-600">
<CircleOff className="h-3 w-3" />
Disabled
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
<TrainFront className="h-3 w-3" />
Active
</span>
),
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Stations" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Stations
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Station options loaded from dropdown code{" "}
<span className="font-mono">stations_ter</span>.
</p>
</div>
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(event) => {
setQuery(event.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search stations..."
className="pl-8!"
/>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StationStat label="Stations" value={stations.length} />
<StationStat label="Active" value={activeCount} />
<StationStat label="Disabled" value={disabledCount} />
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load stations.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="border-b">
<CardTitle>Station List</CardTitle>
<CardDescription>
All configured freight stations from the dropdown service.
</CardDescription>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading stations...
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function StationStat({ label, value }: { label: string; value: number }) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</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-primary/10 text-primary">
<MapPin />
</div>
</CardContent>
</Card>
);
}

View File

@@ -1,3 +1,4 @@
import { Link } from "react-router-dom";
import {
ArrowRight,
BarChart3,
@@ -85,9 +86,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">
EDR Freight
</h1>
<h1 className="text-xl font-bold tracking-tight">EDR Freight</h1>
<p className="text-sm text-muted-foreground">
Rail Logistics Platform
@@ -119,20 +118,20 @@ export default function EDRFreightLandingPage() {
</nav>
<div className="flex items-center gap-3">
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="hidden rounded-2xl border border-border bg-card px-5 py-2.5 text-sm font-medium transition hover:bg-accent md:block"
>
Login
</a>
</Link>
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="hidden items-center gap-2 rounded-2xl bg-primary px-5 py-2.5 text-sm font-semibold text-primary-foreground shadow-lg transition hover:opacity-90 md:flex"
>
Get Started
<ArrowRight className="size-4" />
</a>
</Link>
<button className="rounded-2xl border border-border p-2 md:hidden">
<Menu className="size-5" />
@@ -145,7 +144,7 @@ export default function EDRFreightLandingPage() {
<section className="relative overflow-hidden">
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(16,185,129,0.16),transparent_35%)]" />
<div className="mx-auto grid max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div className="mx-auto grid relative z-10 max-w-7xl gap-16 px-6 py-20 lg:grid-cols-2 lg:items-center">
<div>
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-border bg-accent px-4 py-2 text-sm font-medium text-primary shadow-sm">
<span className="size-2 rounded-full bg-primary" />
@@ -157,26 +156,26 @@ export default function EDRFreightLandingPage() {
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-muted-foreground">
EDR Freight enables logistics companies and railway operators
to manage shipments, monitor freight corridors, optimize train
EDR Freight enables logistics companies and railway operators to
manage shipments, monitor freight corridors, optimize train
operations, and streamline enterprise logistics workflows.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-primary px-6 py-3 font-semibold text-primary-foreground shadow-lg transition hover:-translate-y-0.5 hover:opacity-90"
>
Get Started
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-border bg-card px-6 py-3 font-semibold transition hover:bg-accent"
>
Login
</a>
</Link>
</div>
<div className="mt-12 flex flex-wrap gap-6">
@@ -206,9 +205,7 @@ export default function EDRFreightLandingPage() {
Freight Operations
</p>
<h3 className="mt-2 text-3xl font-bold">
Live Statistics
</h3>
<h3 className="mt-2 text-3xl font-bold">Live Statistics</h3>
</div>
<div className="rounded-2xl bg-primary/10 p-4 text-primary">
@@ -229,9 +226,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-6" />
</div>
<h4 className="text-3xl font-black">
{item.value}
</h4>
<h4 className="text-3xl font-black">{item.value}</h4>
<p className="mt-1 text-sm text-muted-foreground">
{item.label}
@@ -271,9 +266,7 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<h4 className="text-lg font-bold">
16 Trains Active
</h4>
<h4 className="text-lg font-bold">16 Trains Active</h4>
<p className="text-sm text-muted-foreground">
Across all freight corridors
@@ -302,8 +295,8 @@ export default function EDRFreightLandingPage() {
<p className="mt-4 text-lg leading-8 text-muted-foreground">
Centralized railway freight operations with live shipment
visibility, operational monitoring, customer management,
and intelligent logistics insights.
visibility, operational monitoring, customer management, and
intelligent logistics insights.
</p>
</div>
@@ -320,9 +313,7 @@ export default function EDRFreightLandingPage() {
<Icon className="size-7" />
</div>
<h3 className="mt-6 text-xl font-bold">
{feature.title}
</h3>
<h3 className="mt-6 text-xl font-bold">{feature.title}</h3>
<p className="mt-3 leading-7 text-muted-foreground">
{feature.description}
@@ -444,10 +435,7 @@ export default function EDRFreightLandingPage() {
</section>
{/* Contact */}
<section
id="contact"
className="border-t border-border bg-card/40 py-24"
>
<section id="contact" className="border-t border-border bg-card/40 py-24">
<div className="mx-auto max-w-7xl px-6">
<div className="grid gap-12 lg:grid-cols-2">
<div>
@@ -460,8 +448,8 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-5 text-lg leading-8 text-muted-foreground">
Contact EDR Freight for partnership opportunities,
enterprise onboarding, or logistics support.
Contact EDR Freight for partnership opportunities, enterprise
onboarding, or logistics support.
</p>
<div className="mt-10 space-y-5">
@@ -485,9 +473,7 @@ export default function EDRFreightLandingPage() {
<div>
<p className="font-semibold">Phone</p>
<p className="text-muted-foreground">
+251 11 000 0000
</p>
<p className="text-muted-foreground">+251 11 000 0000</p>
</div>
</div>
@@ -508,9 +494,7 @@ export default function EDRFreightLandingPage() {
{/* Contact Form */}
<div className="rounded-[32px] border border-border bg-card p-8 shadow-xl">
<h3 className="text-2xl font-bold">
Send us a message
</h3>
<h3 className="text-2xl font-bold">Send us a message</h3>
<p className="mt-2 text-muted-foreground">
Well get back to you as soon as possible.
@@ -559,26 +543,26 @@ export default function EDRFreightLandingPage() {
</h2>
<p className="mt-6 text-lg leading-8 opacity-85">
Centralize logistics workflows, optimize freight movement,
and gain real-time operational visibility across all
railway corridors.
Centralize logistics workflows, optimize freight movement, and
gain real-time operational visibility across all railway
corridors.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-4">
<a
href="http://localhost:5173/signup"
<Link
to="/signup"
className="flex items-center gap-2 rounded-2xl bg-white px-7 py-3 font-semibold text-primary shadow-lg transition hover:opacity-90"
>
Create Account
<ArrowRight className="size-5" />
</a>
</Link>
<a
href="http://localhost:5173/auth"
<Link
to="/login"
className="rounded-2xl border border-white/20 bg-white/10 px-7 py-3 font-semibold backdrop-blur transition hover:bg-white/20"
>
Sign In
</a>
</Link>
</div>
</div>
</div>
@@ -594,19 +578,15 @@ export default function EDRFreightLandingPage() {
</div>
<div>
<p className="font-semibold text-foreground">
EDR Freight
</p>
<p className="font-semibold text-foreground">EDR Freight</p>
<p>Modern railway logistics management platform</p>
</div>
</div>
<div>
© 2026 EDR Freight. All rights reserved.
</div>
<div>© 2026 EDR Freight. All rights reserved.</div>
</div>
</footer>
</div>
);
}
}

View File

@@ -0,0 +1,360 @@
import { useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
import {
Button,
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@edr/ui-common";
export default function MyPortalPage() {
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const totalSpent = myInvoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-7xl space-y-6">
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">{me.name}</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link to="/bookings/new">
<Button
variant="secondary"
className="bg-white text-[#10B981] hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</Button>
</Link>
<Link to="/tracking">
<Button
variant="outline"
className="border-white/40 text-white hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Button>
</Link>
</div>
</div>
</div>
{/* Active Shipments */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Active Shipments</CardTitle>
<CardDescription>
Live tracking for your in-flight cargo
</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-primary" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent bookings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Bookings</CardTitle>
<CardDescription>Your latest freight requests</CardDescription>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent className="px-0">
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="px-6 py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="px-6 py-2 text-right font-medium">
Action
</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-primary/5"
>
<td className="px-6 py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="px-6 py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-primary/10 hover:text-primary"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Invoices */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>Recent Invoices</CardTitle>
<CardDescription>
{outstandingInvoices.length} outstanding · {myInvoices.length}{" "}
total
</CardDescription>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-primary transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</CardHeader>
<CardContent>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-primary/20 hover:bg-primary/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-primary" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-0.5 pt-2 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,77 +0,0 @@
import { useState, 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;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteDropdownSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteDropdownSettingDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete dropdown setting?
</DialogTitle>
<DialogDescription>
This will remove{" "}
<span className="font-semibold text-slate-900">{settingLabel}</span>{" "}
(<span className="font-mono text-xs">{settingCode}</span>) and all
of its options. Forms referencing this code will fall back to
empty options.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,65 +0,0 @@
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 DeleteFileUploadSettingDialogProps {
settingLabel: string;
settingCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteFileUploadSettingDialog({
settingLabel,
settingCode,
onConfirm,
children,
}: DeleteFileUploadSettingDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete file upload 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 fields. Forms referencing this code will fall back to no
uploads.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,468 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
Boxes,
CheckCircle2,
Eye,
Filter,
ListOrdered,
Loader2,
MoreHorizontal,
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 {
useDeleteDropdownSetting,
useDropdownSettings,
} from "@/hooks/useDropdownSettings";
import type { DropdownSetting } from "@/types/dropdownSettings";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "options" | "delete";
export default function DropdownSettingsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeSetting, setActiveSetting] = useState<DropdownSetting | null>(
null,
);
const openDialogFor = (dialog: ActiveDialog, setting: DropdownSetting) => {
// Defer past the DropdownMenu's close cycle. Radix's modal lock can leave
// `pointer-events: none` on <body> when a menu closes and a dialog opens
// in the same frame — wait two RAFs and then explicitly reset the body
// style so the dialog interior is interactive.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveSetting(setting);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => {
setActiveDialog(null);
// Keep activeSetting briefly so dialog content doesn't flash empty during
// the close animation; cleared on next open.
};
// Belt-and-suspenders for the Radix pointer-events leak: any time the active
// dialog changes, schedule a body-style cleanup after the next paint.
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useDropdownSettings();
const deleteMutation = useDeleteDropdownSetting();
const dropdownSettings = useMemo<DropdownSetting[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
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),
);
}, [dropdownSettings, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const totalOptions = dropdownSettings.reduce(
(sum, s) => sum + (s.children?.length ?? 0),
0,
);
const multipleCount = dropdownSettings.filter((s) => s.multiple).length;
const searchableCount = dropdownSettings.filter(
(s) => s.meta?.searchable,
).length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<DropdownSetting>[] = [
{
id: "setting",
header: "Setting",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Settings />
</div>
<div>
<p className="font-medium text-slate-900">{s.label}</p>
<p className="text-xs text-slate-500">
{s.description ?? "No description"}
</p>
</div>
</div>
);
},
},
{
id: "code",
header: "Code",
cell: ({ row }) => (
<span className="rounded-md bg-slate-100 px-2 py-1 font-mono text-xs text-slate-700">
{row.original.code}
</span>
),
},
{
id: "options",
header: "Options",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex items-center gap-2 text-sm text-slate-700">
<Boxes />
<span className="font-medium">{s.children?.length ?? 0}</span>
</div>
);
},
},
{
id: "behavior",
header: "Behavior",
cell: ({ row }) => {
const s = row.original;
return (
<div className="flex flex-wrap gap-1">
{s.multiple ? (
<BehaviorChip label="Multi" />
) : (
<BehaviorChip label="Single" muted />
)}
{s.meta?.searchable ? <BehaviorChip label="Searchable" /> : null}
{s.meta?.clearable ? <BehaviorChip label="Clearable" /> : null}
</div>
);
},
},
{
id: "permissions",
header: "Permissions",
cell: ({ row }) => {
const s = row.original;
const perms = s.meta?.permissions ?? [];
return (
<div className="flex flex-wrap items-center gap-1">
{perms.length === 0 ? (
<span className="text-xs text-slate-400"></span>
) : (
perms.map((p) => (
<span
key={p}
className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
<Shield />
{p}
</span>
))
)}
</div>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const setting = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("options", setting)}
>
<CheckCircle2 />
Options
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("edit", setting)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", setting)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Admin", href: "/admin" },
{ label: "Dropdown Settings" },
]}
/>
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Dropdown Settings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
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-2 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);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search by code, label, description..."
className="pl-8!"
/>
</div>
<EditDropdownSettingDialog mode="create">
<Button>
<Plus />
New Setting
</Button>
</EditDropdownSettingDialog>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Settings"
value={dropdownSettings.length}
icon={<Settings />}
/>
<StatCard
label="Total Options"
value={totalOptions}
icon={<Boxes />}
/>
<StatCard
label="Multi-select"
value={multipleCount}
icon={<ListOrdered />}
/>
<StatCard
label="Searchable"
value={searchableCount}
icon={<Sparkles />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load dropdown settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Registered Dropdowns</CardTitle>
<CardDescription>
Every dynamic dropdown the platform reads from.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading dropdown settings
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* Controlled dialogs — hoisted out of the DropdownMenu so they can open
reliably after a menu item is selected. */}
{activeSetting ? (
<>
<EditDropdownSettingDialog
key={`edit-${activeSetting.id}`}
mode="edit"
setting={activeSetting}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<ManageDropdownOptionsDialog
key={`options-${activeSetting.id}`}
setting={activeSetting}
open={activeDialog === "options"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteDropdownSettingDialog
key={`delete-${activeSetting.id}`}
settingLabel={activeSetting.label}
settingCode={activeSetting.code}
onConfirm={() => deleteMutation.mutate(activeSetting.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</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-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}
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-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
}
>
{label}
</span>
);
}

View File

@@ -1,336 +0,0 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
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 {
CreateDropdownSettingDto,
DropdownSetting,
UpdateDropdownSettingDto,
} from "@/types/dropdownSettings";
import {
useCreateDropdownSetting,
useUpdateDropdownSetting,
} from "@/hooks/useDropdownSettings";
export interface EditDropdownSettingDialogProps {
mode?: "create" | "edit";
setting?: DropdownSetting;
/** Optional trigger element. When omitted, the dialog renders content only and is fully controlled. */
children?: ReactNode;
/** Controlled open state. When provided, internal state is ignored. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function parsePermissions(raw: string): string[] {
return raw
.split(",")
.map((s) => s.trim())
.filter(Boolean);
}
export default function EditDropdownSettingDialog({
mode = "create",
setting,
children,
open: openProp,
onOpenChange,
}: EditDropdownSettingDialogProps) {
const isEdit = mode === "edit";
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [code, setCode] = useState(setting?.code ?? "");
const [label, setLabel] = useState(setting?.label ?? "");
const [description, setDescription] = useState(setting?.description ?? "");
const [icon, setIcon] = useState(setting?.meta?.icon ?? "");
const [color, setColor] = useState(setting?.meta?.color ?? "");
const [permissions, setPermissions] = useState(
setting?.meta?.permissions?.join(", ") ?? "",
);
const [version, setVersion] = useState(setting?.meta?.version ?? "1.0");
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,
);
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateDropdownSetting();
const updateMutation = useUpdateDropdownSetting();
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {
setCode(setting?.code ?? "");
setLabel(setting?.label ?? "");
setDescription(setting?.description ?? "");
setIcon(setting?.meta?.icon ?? "");
setColor(setting?.meta?.color ?? "");
setPermissions(setting?.meta?.permissions?.join(", ") ?? "");
setVersion(setting?.meta?.version ?? "1.0");
setMultiple(setting?.multiple ?? false);
setSearchable(setting?.meta?.searchable ?? false);
setClearable(setting?.meta?.clearable ?? false);
setError(null);
};
const buildPayload = (): CreateDropdownSettingDto => ({
code: code.trim(),
label: label.trim(),
description: description.trim() || undefined,
multiple,
meta: {
...(icon.trim() ? { icon: icon.trim() } : {}),
...(color.trim() ? { color: color.trim() } : {}),
searchable,
clearable,
...(version.trim() ? { version: version.trim() } : {}),
permissions: parsePermissions(permissions),
},
});
const handleSubmit = () => {
setError(null);
if (!code.trim() || !label.trim()) {
setError("Code and label are required.");
return;
}
if (!/^[a-z][a-z0-9_]*$/i.test(code.trim())) {
setError(
"Code must start with a letter and contain only letters, digits, or underscores.",
);
return;
}
const payload = buildPayload();
const onDone = () => {
setOpen(false);
if (!isEdit) reset();
};
const onError = (err: unknown) => {
setError(
err instanceof Error
? err.message
: "Something went wrong. Try again.",
);
};
if (isEdit && setting) {
// Update DTO omits `code` (immutable); strip it before sending.
const { code: _unused, ...updateDto } = payload;
void _unused;
updateMutation.mutate(
{ id: setting.id, dto: updateDto as UpdateDropdownSettingDto },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<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
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. cargo_type"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Cargo Type"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this dropdown represents and where it's used..."
/>
</div>
<div className="space-y-2">
<Label>Icon (meta.icon)</Label>
<Input
value={icon}
onChange={(e) => setIcon(e.target.value)}
placeholder="lucide icon name, e.g. package"
/>
</div>
<div className="space-y-2">
<Label>Color (meta.color)</Label>
<Input
value={color}
onChange={(e) => setColor(e.target.value)}
placeholder="#10B981"
/>
</div>
<div className="space-y-2">
<Label>Permissions (comma-separated)</Label>
<Input
value={permissions}
onChange={(e) => setPermissions(e.target.value)}
placeholder="admin, ops"
/>
</div>
<div className="space-y-2">
<Label>Version (meta.version)</Label>
<Input
value={version}
onChange={(e) => setVersion(e.target.value)}
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>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSubmit}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : 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-[#10B981]/40 bg-[#10B981]/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-[#10B981]/30 hover:bg-[#10B981]/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-[#10B981] focus:ring-[#10B981]/20"
/>
<div>
<p className="font-medium text-slate-900">{label}</p>
<p className="text-xs text-slate-500">{description}</p>
</div>
</label>
);
}

View File

@@ -1,229 +0,0 @@
import { useState, type ReactNode } from "react";
import { Hash, Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
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 {
FileUploadEntity,
FileUploadSetting,
} from "@/types/fileUploadSettings";
import {
useCreateFileUploadSetting,
useUpdateFileUploadSetting,
} from "@/hooks/useFileUploadSettings";
export interface EditFileUploadSettingDialogProps {
mode?: "create" | "edit";
setting?: FileUploadSetting;
children: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
const ENTITIES: FileUploadEntity[] = [
"customer",
"booking",
"consignment",
"shipment",
"invoice",
"train",
"other",
];
export default function EditFileUploadSettingDialog({
mode = "create",
setting,
children,
}: EditFileUploadSettingDialogProps) {
const isEdit = mode === "edit";
const [open, setOpen] = useState(false);
const [code, setCode] = useState(setting?.code ?? "");
const [label, setLabel] = useState(setting?.label ?? "");
const [entity, setEntity] = useState<FileUploadEntity>(
setting?.entity ?? "other",
);
const [description, setDescription] = useState(setting?.description ?? "");
const [error, setError] = useState<string | null>(null);
const createMutation = useCreateFileUploadSetting();
const updateMutation = useUpdateFileUploadSetting();
const pending = createMutation.isPending || updateMutation.isPending;
const reset = () => {
setCode(setting?.code ?? "");
setLabel(setting?.label ?? "");
setEntity(setting?.entity ?? "other");
setDescription(setting?.description ?? "");
setError(null);
};
const handleSubmit = () => {
setError(null);
if (!code.trim() || !label.trim()) {
setError("Code and label are required.");
return;
}
const payload = {
code: code.trim(),
label: label.trim(),
entity,
description: description.trim() || undefined,
};
const onDone = () => {
setOpen(false);
if (!isEdit) reset();
};
const onError = (err: unknown) => {
setError(
err instanceof Error ? err.message : "Something went wrong. Try again.",
);
};
if (isEdit && setting) {
updateMutation.mutate(
{ id: setting.id, dto: payload },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) reset();
}}
>
<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 File Upload Setting" : "New File Upload Setting"}
</DialogTitle>
<DialogDescription>
{isEdit
? "Update the metadata for this file upload group."
: "Define a new file upload group that a form can reference by code."}
</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
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="e.g. customer_registration"
className="pl-10 font-mono"
disabled={isEdit}
/>
</div>
<p className="text-xs text-slate-500">
{isEdit
? "Code is immutable after creation."
: "Stable identifier used in code. Use snake_case."}
</p>
</div>
<div className="space-y-2">
<Label>Label *</Label>
<Input
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g. Customer Registration"
/>
</div>
<div className="space-y-2">
<Label>Entity</Label>
<select
value={entity}
onChange={(e) => setEntity(e.target.value as FileUploadEntity)}
className={selectClass}
>
{ENTITIES.map((e) => (
<option key={e} value={e} className="capitalize">
{e[0]!.toUpperCase() + e.slice(1)}
</option>
))}
</select>
<p className="text-xs text-slate-500">
Domain the upload group applies to.
</p>
</div>
<div className="space-y-2">
<Label>Field Count</Label>
<Input
disabled
value={String(setting?.fields.length ?? 0)}
className="bg-slate-50 text-slate-600"
/>
<p className="text-xs text-slate-500">
Manage fields from the "Fields" action on the list.
</p>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="What this upload group represents and where it's used..."
/>
</div>
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSubmit}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : isEdit ? (
"Save Changes"
) : (
"Create Setting"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,460 +0,0 @@
import { useMemo, useState } from "react";
import {
AlertCircle,
Filter,
FileUp,
HardDrive,
Layers,
Loader2,
Paperclip,
Pencil,
Plus,
Search,
Settings,
Trash2,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import EditFileUploadSettingDialog from "./EditFileUploadSettingDialog";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
import DeleteFileUploadSettingDialog from "./DeleteFileUploadSettingDialog";
import {
useDeleteFileUploadSetting,
useFileUploadSettings,
} from "@/hooks/useFileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
const { data, isLoading, isError, error } = useFileUploadSettings();
const deleteMutation = useDeleteFileUploadSetting();
const fileUploadSettings = useMemo(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return fileUploadSettings;
return fileUploadSettings.filter(
(s) =>
s.code.toLowerCase().includes(q) ||
s.label.toLowerCase().includes(q) ||
(s.description ?? "").toLowerCase().includes(q) ||
s.fields.some(
(f) =>
f.fileKey.toLowerCase().includes(q) ||
f.fileLabel.toLowerCase().includes(q),
),
);
}, [fileUploadSettings, query]);
const totalFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.length,
0,
);
const requiredFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isRequired).length,
0,
);
const multiFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.filter((f) => f.isMultiple).length,
0,
);
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: "File Upload 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">
File Upload Settings
</h1>
<p className="mt-1 text-sm text-slate-500">
Define the file inputs every form in the platform should render
required/optional, single/multiple, allowed types and size.
</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, or file key..."
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
/>
</div>
<EditFileUploadSettingDialog mode="create">
<button
type="button"
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-4 w-4" />
New Setting
</button>
</EditFileUploadSettingDialog>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">
<StatCard
title="Settings"
value={String(fileUploadSettings.length)}
icon={<Settings className="h-5 w-5" />}
/>
<StatCard
title="Total Fields"
value={String(totalFields)}
icon={<Paperclip className="h-5 w-5" />}
/>
<StatCard
title="Required"
value={String(requiredFields)}
icon={<FileUp className="h-5 w-5" />}
/>
<StatCard
title="Multi-file"
value={String(multiFields)}
icon={<Layers 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 File Upload Groups
</h2>
<p className="text-sm text-slate-500">
Every group a form can reference by code.
</p>
</div>
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]">
<Filter className="h-4 w-4" />
Filter
</button>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[1100px] 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">Entity</th>
<th className="px-6 py-4 font-medium">Fields</th>
<th className="px-6 py-4 font-medium">Required / Multi</th>
<th className="px-6 py-4 font-medium">Max Size</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<Loader2 className="mx-auto h-6 w-6 animate-spin text-[#10B981]" />
<p className="mt-2 text-sm text-slate-500">
Loading file upload settings
</p>
</td>
</tr>
) : isError ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center">
<AlertCircle className="mx-auto h-6 w-6 text-red-500" />
<p className="mt-2 text-sm text-red-600">
Failed to load settings.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</p>
</td>
</tr>
) : filtered.length === 0 ? (
<tr>
<td
colSpan={7}
className="px-6 py-12 text-center text-sm text-slate-500"
>
{fileUploadSettings.length === 0
? "No file upload settings yet. Click \"New Setting\" to add one."
: "No file upload settings match your search."}
</td>
</tr>
) : (
filtered.map((setting) => {
const required = setting.fields.filter(
(f) => f.isRequired,
).length;
const multi = setting.fields.filter(
(f) => f.isMultiple,
).length;
const maxSize = Math.max(
0,
...setting.fields.map((f) => f.maxSizeMb),
);
return (
<tr
key={setting.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#10B981] text-white">
<FileUp 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">
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium capitalize text-slate-600">
{setting.entity ?? "—"}
</span>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-sm text-slate-700">
<Paperclip className="h-4 w-4 text-[#10B981]" />
<span className="font-medium">
{setting.fields.length}
</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex flex-wrap items-center gap-1">
<Chip>{required} required</Chip>
<Chip muted>{multi} multi</Chip>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
<div className="flex items-center gap-1.5">
<HardDrive className="h-4 w-4 text-slate-400" />
{maxSize ? `${maxSize} MB` : "—"}
</div>
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<ManageFileUploadFieldsDialog setting={setting}>
<button
type="button"
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Paperclip className="h-3.5 w-3.5" />
Fields
</button>
</ManageFileUploadFieldsDialog>
<EditFileUploadSettingDialog
mode="edit"
setting={setting}
>
<button
type="button"
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Pencil className="h-4 w-4" />
</button>
</EditFileUploadSettingDialog>
<DeleteFileUploadSettingDialog
settingLabel={setting.label}
settingCode={setting.code}
onConfirm={() =>
deleteMutation.mutate(setting.id)
}
>
<button
type="button"
disabled={deleteMutation.isPending}
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50 disabled:cursor-not-allowed disabled:opacity-50"
>
<Trash2 className="h-4 w-4" />
</button>
</DeleteFileUploadSettingDialog>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Behavior reference card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
Required × Multiple behavior
</h2>
<p className="mt-1 text-sm text-slate-500">
Min and max file counts are derived from these two toggles. The
"Max Files" you set on a field is only used when{" "}
<span className="font-medium">Multiple</span> is on.
</p>
<div className="mt-4 overflow-x-auto">
<table className="w-full whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Required</th>
<th className="py-2 font-medium">Multiple</th>
<th className="py-2 font-medium">min_files</th>
<th className="py-2 font-medium">max_files</th>
</tr>
</thead>
<tbody>
<BehaviorRow
required={false}
multiple={false}
min="0"
max="1"
/>
<BehaviorRow
required={true}
multiple={false}
min="1"
max="1"
/>
<BehaviorRow
required={false}
multiple={true}
min="0"
max="field.maxFiles"
/>
<BehaviorRow
required={true}
multiple={true}
min="1"
max="field.maxFiles"
/>
</tbody>
</table>
</div>
<p className="mt-3 text-xs text-slate-500">
Helpers <span className="font-mono">getMinFiles</span> and{" "}
<span className="font-mono">getEffectiveMaxFiles</span> live in{" "}
<span className="font-mono">@/types/fileUploadSettings</span> use
them when wiring real uploaders. Example: a field with{" "}
<span className="font-mono">isRequired=false</span>,{" "}
<span className="font-mono">isMultiple=true</span>,{" "}
<span className="font-mono">maxFiles=5</span> gives{" "}
<span className="font-mono">{getMinFiles({
id: "demo",
fileKey: "demo",
fileLabel: "demo",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: [],
maxSizeMb: 1,
})}</span>
5.
</p>
</div>
</div>
</div>
);
}
function BehaviorRow({
required,
multiple,
min,
max,
}: {
required: boolean;
multiple: boolean;
min: string;
max: string;
}) {
return (
<tr className="border-t border-slate-100">
<td className="py-2.5">
<Chip muted={!required}>{required ? "Required" : "Optional"}</Chip>
</td>
<td className="py-2.5">
<Chip muted={!multiple}>{multiple ? "Multiple" : "Single"}</Chip>
</td>
<td className="py-2.5 font-mono text-slate-700">{min}</td>
<td className="py-2.5 font-mono text-slate-700">{max}</td>
</tr>
);
}
function Chip({
children,
muted = false,
}: {
children: React.ReactNode;
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-[#10B981]/10 px-2 py-0.5 text-xs font-medium text-[#10B981]"
}
>
{children}
</span>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: string;
icon: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{title}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#10B981] text-white">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -1,339 +0,0 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogClose,
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 {
CreateDropdownOptionDto,
DropdownSetting,
} from "@/types/dropdownSettings";
import { useReplaceDropdownOptions } from "@/hooks/useDropdownSettings";
export interface ManageDropdownOptionsDialogProps {
setting: DropdownSetting;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
/**
* Local draft used by the editor — uses a stable client-only `key` so React
* keys remain stable across reorders. On save we strip `key` and POST the
* remainder as CreateDropdownOptionDto[].
*/
interface DraftOption extends CreateDropdownOptionDto {
key: string;
}
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
function makeEmptyDraft(idx: number): DraftOption {
return {
key: nextKey(),
value: "",
label: "",
disabled: false,
order: idx + 1,
meta: {},
};
}
export default function ManageDropdownOptionsDialog({
setting,
children,
open: openProp,
onOpenChange,
}: ManageDropdownOptionsDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [error, setError] = useState<string | null>(null);
const seed = (): DraftOption[] =>
[...(setting.children ?? [])]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((o, idx) => ({
key: o.id,
value: o.value,
label: o.label,
note: o.note ?? undefined,
disabled: o.disabled,
order: o.order ?? idx + 1,
meta: {
...(o.meta?.icon ? { icon: o.meta.icon } : {}),
...(o.meta?.color ? { color: o.meta.color } : {}),
...(o.meta?.badge ? { badge: o.meta.badge } : {}),
},
}));
const [options, setOptions] = useState<DraftOption[]>(seed);
const replaceMutation = useReplaceDropdownOptions();
const update = (i: number, patch: Partial<DraftOption>) =>
setOptions((prev) =>
prev.map((o, idx) => (idx === i ? { ...o, ...patch } : o)),
);
const updateMeta = (
i: number,
patch: Partial<NonNullable<DraftOption["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, makeEmptyDraft(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 DraftOption;
const b = next[target] as DraftOption;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
const handleSave = () => {
setError(null);
const invalid = options.findIndex(
(o) => !o.label.trim() || !o.value.trim(),
);
if (invalid >= 0) {
setError(`Option ${invalid + 1} is missing a label or value.`);
return;
}
const payload: CreateDropdownOptionDto[] = options.map((o, idx) => {
const meta: NonNullable<CreateDropdownOptionDto["meta"]> = {};
if (o.meta?.icon?.trim()) meta.icon = o.meta.icon.trim();
if (o.meta?.color?.trim()) meta.color = o.meta.color.trim();
if (o.meta?.badge?.trim()) meta.badge = o.meta.badge.trim();
return {
value: o.value.trim(),
label: o.label.trim(),
note: o.note?.trim() || undefined,
disabled: o.disabled ?? false,
order: idx + 1,
...(Object.keys(meta).length > 0 ? { meta } : {}),
};
});
replaceMutation.mutate(
{ settingId: setting.id, options: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>
setError(
err instanceof Error
? err.message
: "Failed to save options. Try again.",
),
},
);
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setOptions(seed());
if (!next) setError(null);
}}
>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<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-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/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.key}
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-[#10B981] 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-[#10B981] 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
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-[#10B981] focus:ring-[#10B981]/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>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="flex justify-end gap-3 border-t border-slate-100 pt-3">
<DialogClose asChild>
<Button variant="outline" disabled={replaceMutation.isPending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSave}
disabled={replaceMutation.isPending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{replaceMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save Options"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,416 +0,0 @@
import { useState, type ReactNode } from "react";
import { GripVertical, Loader2, Plus, Trash2 } from "lucide-react";
import {
Dialog,
DialogClose,
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 {
CreateFileUploadFieldDto,
FileUploadSetting,
} from "@/types/fileUploadSettings";
import { getMinFiles } from "@/types/fileUploadSettings";
import { useReplaceFileUploadFields } from "@/hooks/useFileUploadSettings";
export interface ManageFileUploadFieldsDialogProps {
setting: FileUploadSetting;
children: ReactNode;
}
/**
* Local draft used by the editor — does NOT need to satisfy IFileUploadField
* (which carries server-only props like createdAt). On save, we strip the
* client-only `key` and post the rest as CreateFileUploadFieldDto[].
*/
interface DraftField extends CreateFileUploadFieldDto {
key: string;
}
let draftCounter = 0;
const nextKey = () => `draft-${Date.now()}-${++draftCounter}`;
function makeEmptyDraft(idx: number): DraftField {
return {
key: nextKey(),
fileKey: "",
fileLabel: "",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
order: idx + 1,
};
}
export default function ManageFileUploadFieldsDialog({
setting,
children,
}: ManageFileUploadFieldsDialogProps) {
const [open, setOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const seed = (): DraftField[] =>
[...setting.fields]
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((f, idx) => ({
key: f.id,
fileKey: f.fileKey,
fileLabel: f.fileLabel,
helpText: f.helpText ?? undefined,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.maxFiles,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
order: f.order ?? idx + 1,
}));
const [fields, setFields] = useState<DraftField[]>(seed);
const replaceMutation = useReplaceFileUploadFields();
const update = (i: number, patch: Partial<DraftField>) =>
setFields((prev) =>
prev.map((f, idx) => {
if (idx !== i) return f;
const next = { ...f, ...patch };
if (patch.isMultiple === false) next.maxFiles = 1;
if (patch.isMultiple === true && next.maxFiles <= 1) next.maxFiles = 5;
return next;
}),
);
const updateExtensions = (i: number, raw: string) => {
const list = raw
.split(",")
.map((s) => s.trim().toLowerCase().replace(/^\./, ""))
.filter(Boolean);
update(i, { allowedExtensions: list });
};
const remove = (i: number) =>
setFields((prev) => prev.filter((_, idx) => idx !== i));
const add = () =>
setFields((prev) => [...prev, makeEmptyDraft(prev.length)]);
const move = (i: number, dir: -1 | 1) =>
setFields((prev) => {
const next = [...prev];
const target = i + dir;
if (target < 0 || target >= next.length) return prev;
const a = next[i] as DraftField;
const b = next[target] as DraftField;
next[i] = { ...b, order: i + 1 };
next[target] = { ...a, order: target + 1 };
return next;
});
const handleSave = () => {
setError(null);
const invalid = fields.findIndex(
(f) =>
!f.fileKey.trim() ||
!f.fileLabel.trim() ||
f.allowedExtensions.length === 0,
);
if (invalid >= 0) {
setError(
`Field ${invalid + 1} is missing file key, label, or extensions.`,
);
return;
}
const payload: CreateFileUploadFieldDto[] = fields.map((f, idx) => ({
fileKey: f.fileKey.trim(),
fileLabel: f.fileLabel.trim(),
helpText: f.helpText?.trim() || undefined,
isRequired: f.isRequired,
isMultiple: f.isMultiple,
maxFiles: f.isMultiple ? Math.max(1, f.maxFiles) : 1,
allowedExtensions: f.allowedExtensions,
maxSizeMb: f.maxSizeMb,
order: idx + 1,
}));
replaceMutation.mutate(
{ settingId: setting.id, fields: payload },
{
onSuccess: () => setOpen(false),
onError: (err) =>
setError(
err instanceof Error
? err.message
: "Failed to save fields. Try again.",
),
},
);
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setFields(seed());
setError(null);
}
}}
>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-5xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Manage Fields · {setting.label}
</DialogTitle>
<DialogDescription>
Add, edit, reorder, or remove upload fields 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">
{fields.length} field{fields.length === 1 ? "" : "s"}
</p>
<button
type="button"
onClick={add}
className="inline-flex items-center gap-1.5 rounded-xl bg-[#10B981] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#10B981]/90"
>
<Plus className="h-3.5 w-3.5" />
Add Field
</button>
</div>
{fields.length === 0 ? (
<div className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No fields yet. Click{" "}
<span className="font-medium">Add Field</span> to start.
</div>
) : (
<div className="space-y-3">
{fields.map((f, i) => (
<FieldEditor
key={f.key}
field={f}
index={i}
total={fields.length}
onChange={(patch) => update(i, patch)}
onChangeExtensions={(raw) => updateExtensions(i, raw)}
onMove={(dir) => move(i, dir)}
onRemove={() => remove(i)}
/>
))}
</div>
)}
</div>
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="mt-2 flex justify-end gap-3 border-t border-slate-100 pt-3">
<DialogClose asChild>
<Button variant="outline" disabled={replaceMutation.isPending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSave}
disabled={replaceMutation.isPending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{replaceMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
"Save Fields"
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function FieldEditor({
field,
index,
total,
onChange,
onChangeExtensions,
onMove,
onRemove,
}: {
field: DraftField;
index: number;
total: number;
onChange: (patch: Partial<DraftField>) => void;
onChangeExtensions: (raw: string) => void;
onMove: (dir: -1 | 1) => void;
onRemove: () => void;
}) {
const minFiles = getMinFiles(field);
const effectiveMax = field.isMultiple ? field.maxFiles : 1;
return (
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<div className="mb-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-400">
<GripVertical className="h-4 w-4" />
<div className="flex flex-col">
<button
type="button"
onClick={() => onMove(-1)}
aria-label="Move up"
disabled={index === 0}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
<button
type="button"
onClick={() => onMove(1)}
aria-label="Move down"
disabled={index === total - 1}
className="text-[10px] leading-none text-slate-400 transition hover:text-[#10B981] disabled:opacity-30"
>
</button>
</div>
<span className="text-xs font-semibold uppercase tracking-wide text-[#10B981]">
Field {index + 1}
</span>
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-medium text-slate-600">
min {minFiles} · max {effectiveMax}
</span>
</div>
<button
type="button"
onClick={onRemove}
aria-label="Remove field"
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<div className="grid gap-3 md:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs">File Key *</Label>
<Input
value={field.fileKey}
onChange={(e) => onChange({ fileKey: e.target.value })}
placeholder="supporting_doc"
className="font-mono"
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">File Label *</Label>
<Input
value={field.fileLabel}
onChange={(e) => onChange({ fileLabel: e.target.value })}
placeholder="Supporting Document"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Size (MB)</Label>
<Input
type="number"
min={1}
value={field.maxSizeMb}
onChange={(e) =>
onChange({ maxSizeMb: Number(e.target.value) })
}
/>
</div>
<div className="space-y-1.5 md:col-span-2">
<Label className="text-xs">Allowed Extensions</Label>
<Input
value={field.allowedExtensions.join(", ")}
onChange={(e) => onChangeExtensions(e.target.value)}
placeholder="pdf, docx, jpg"
className="font-mono"
/>
<p className="text-xs text-slate-500">
Comma-separated, no leading dot.
</p>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Files</Label>
<Input
type="number"
min={1}
max={50}
value={field.maxFiles}
disabled={!field.isMultiple}
onChange={(e) =>
onChange({ maxFiles: Number(e.target.value) })
}
className={!field.isMultiple ? "bg-slate-50 text-slate-400" : ""}
/>
{!field.isMultiple ? (
<p className="text-xs text-slate-400">
Locked to 1 when single-file.
</p>
) : null}
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end md:gap-4">
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={field.isRequired}
onChange={(e) =>
onChange({ isRequired: e.target.checked })
}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Required
</label>
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
checked={field.isMultiple}
onChange={(e) =>
onChange({ isMultiple: e.target.checked })
}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
Multiple
</label>
</div>
<div className="space-y-1.5 md:col-span-4">
<Label className="text-xs">Help Text (optional)</Label>
<Input
value={field.helpText ?? ""}
onChange={(e) => onChange({ helpText: e.target.value })}
placeholder="e.g. PDF or photo of the original document."
/>
</div>
</div>
</div>
);
}

View File

@@ -1,174 +0,0 @@
import type {
FileUploadField,
FileUploadSetting,
} from "@/types/fileUploadSettings";
const field = (
settingCode: string,
idx: number,
data: Omit<FileUploadField, "id" | "order">,
): FileUploadField => ({
id: `${settingCode}-${idx + 1}`,
order: idx + 1,
...data,
});
export const fileUploadSettings: FileUploadSetting[] = [
{
id: "fu-customer_registration",
code: "customer_registration",
label: "Customer Registration",
description: "Documents required when onboarding a new customer.",
entity: "customer",
fields: [
field("customer_registration", 0, {
fileKey: "tin_certificate",
fileLabel: "TIN Certificate",
helpText: "Tax Identification Number certificate.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
field("customer_registration", 1, {
fileKey: "trade_license",
fileLabel: "Trade License",
helpText: "Current, non-expired trade license.",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-booking",
code: "booking",
label: "Freight Booking",
description: "Documents attached to a freight booking submission.",
entity: "booking",
fields: [
field("booking", 0, {
fileKey: "packing_list",
fileLabel: "Packing List",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "xlsx"],
maxSizeMb: 10,
}),
field("booking", 1, {
fileKey: "commercial_invoice",
fileLabel: "Commercial Invoice",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("booking", 2, {
fileKey: "certificate_of_origin",
fileLabel: "Certificate of Origin",
helpText: "Optional. Required for international shipments.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-consignment",
code: "consignment",
label: "Consignment",
description: "Cargo-level documents.",
entity: "consignment",
fields: [
field("consignment", 0, {
fileKey: "bill_of_lading",
fileLabel: "Bill of Lading",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("consignment", 1, {
fileKey: "supporting_doc",
fileLabel: "Supporting Documents",
helpText: "Customs declarations, inspection reports, etc.",
isRequired: false,
isMultiple: true,
maxFiles: 5,
allowedExtensions: ["pdf", "docx", "jpg", "png"],
maxSizeMb: 10,
}),
],
},
{
id: "fu-invoice",
code: "invoice",
label: "Invoice",
description: "Attachments for billing invoices.",
entity: "invoice",
fields: [
field("invoice", 0, {
fileKey: "proof_of_payment",
fileLabel: "Proof of Payment",
helpText: "Bank transfer receipt or wire confirmation.",
isRequired: false,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf", "jpg", "png"],
maxSizeMb: 5,
}),
],
},
{
id: "fu-train",
code: "train_maintenance",
label: "Train Maintenance",
description: "Maintenance and inspection records for rolling stock.",
entity: "train",
fields: [
field("train_maintenance", 0, {
fileKey: "inspection_report",
fileLabel: "Inspection Report",
isRequired: true,
isMultiple: false,
maxFiles: 1,
allowedExtensions: ["pdf"],
maxSizeMb: 10,
}),
field("train_maintenance", 1, {
fileKey: "photos",
fileLabel: "Inspection Photos",
helpText: "Photos of the condition.",
isRequired: false,
isMultiple: true,
maxFiles: 10,
allowedExtensions: ["jpg", "png", "heic"],
maxSizeMb: 8,
}),
],
},
];
export function getFileUploadSettingByCode(
code: string,
): FileUploadSetting | undefined {
return fileUploadSettings.find((s) => s.code === code);
}
export function getFileUploadFieldsByCode(
code: string,
): FileUploadField[] {
const setting = getFileUploadSettingByCode(code);
if (!setting) return [];
return [...setting.fields].sort(
(a, b) => (a.order ?? 0) - (b.order ?? 0),
);
}

View File

@@ -1,282 +0,0 @@
import { useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
ArrowRight,
Clock,
Eye,
Filter,
MoreHorizontal,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { bookings, type BookingStatus } from "./bookings.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
export default function BookingsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const total = bookings.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(() => bookings.slice(start, end), [start, end]);
const columns: ColumnDef<(typeof bookings)[number]>[] = [
{
accessorKey: "reference",
header: "Reference",
cell: ({ row }) => {
const booking = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package />
</div>
<div>
<p className="font-medium text-slate-900">{booking.reference}</p>
<p className="text-sm text-slate-500">{booking.requestedDate}</p>
</div>
</div>
);
},
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="text-sm text-slate-700">
<p>{b.cargoType}</p>
<p className="text-xs text-slate-500">
{b.containerCount} × {b.containerType} · {b.weightTons}t
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const booking = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/bookings/${booking.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuSeparator />
<DeleteBookingDialog bookingReference={booking.reference}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteBookingDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Bookings" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Bookings
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Manage and monitor your freight bookings.
</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-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
placeholder="Search bookings..."
className="pl-8!"
/>
</div>
<Link to="/bookings/new">
<Button>
<Plus />
New Booking
</Button>
</Link>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Bookings</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{total}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Transit</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "In Transit").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Pending</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{bookings.filter((b) => b.status === "Pending").length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Clock />
</div>
</CardContent>
</Card>
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Booking List</CardTitle>
<CardDescription>
Recent freight bookings and their status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
navigate(`/bookings/${(row as (typeof bookings)[number]).id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -13,7 +13,6 @@ import {
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { getMyBookings } from "@/lib/currentCustomer";
import { deleteBooking, type Booking, type BookingStatus } from "./bookings.mock";
@@ -183,8 +182,6 @@ export default function MyBookings() {
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "My Bookings" }]} />
{/* Header Section Card */}
<Card className="p-6 flex-row justify-between">
<div>

View File

@@ -1,12 +1,11 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { CheckCircle2, ChevronLeft, ChevronRight } from "lucide-react";
import { Button } from "@edr/ui-common";
import Breadcrumbs from "@/components/Breadcrumbs";
import { getCurrentCustomer } from "@/lib/currentCustomer";
import { api } from "@/services/api";
import type { CreateBookingPayload } from "@/services/bookings.service";
import {
@@ -30,6 +29,7 @@ import {
Step7Documents,
Step8Review,
} from "./new-booking-form/steps";
import useAuth from "@/hooks/useAuth";
export default function NewBookingPage() {
const navigate = useNavigate();
@@ -37,6 +37,7 @@ export default function NewBookingPage() {
const [step, setStep] = useState(1);
const [renewalValidating, setRenewalValidating] = useState(false);
const [renewalValid, setRenewalValid] = useState<boolean | null>(null);
const { customer } = useAuth();
const createMutation = useMutation({
mutationFn: (payload: CreateBookingPayload) =>
api.bookings.create.call(payload),
@@ -129,7 +130,6 @@ export default function NewBookingPage() {
return;
}
const me = getCurrentCustomer();
const reference = data.previousContractRef;
const totalWeight =
@@ -142,7 +142,7 @@ export default function NewBookingPage() {
const apiPayload = {
reference,
customerId: String(me.id),
customerId: customer!.id,
scheduledDate: new Date().toISOString().slice(0, 10),
totalAmount: 0,
contractType:
@@ -194,7 +194,7 @@ export default function NewBookingPage() {
createMutation.mutate(apiPayload);
});
if (createMutation.isSuccess && createMutation.data) {
if (createMutation.isSuccess) {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm rounded-2xl border border-border bg-card p-10 text-center shadow-sm">
@@ -207,7 +207,7 @@ export default function NewBookingPage() {
notified once approved.
</p>
<p className="mt-4 font-mono text-sm font-semibold text-primary">
{createMutation.data.reference}
{createMutation.data?.reference}
</p>
</div>
</div>

View File

@@ -161,7 +161,7 @@ export const bookingFormSchema = z
destinationYard: z.string(),
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
cargoWeight: z.string(),
freightType: z.enum(["bulk", "break_bulk"]),
freightType: z.enum(["bulk", "break_bulk"]).optional(),
bulkCommodity: z.string(),
bulkCommodityOther: z.string(),
breakBulkType: z.string(),

View File

@@ -2,7 +2,13 @@ import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, MapPin, Snowflake } from "lucide-react";
import { Field, SelectItem, Separator, Switch } from "@edr/ui-common";
import { type BookingFormValues, getRouteDirection, STATIONS } from "./schema";
import { SelectField, SelectOptions, StepHeader, StepLabel } from "./shared";
import {
AlertBox,
SelectField,
SelectOptions,
StepHeader,
StepLabel,
} from "./shared";
import { useDropdownSettingByCode } from "@/hooks/useDropdownSettings";
import { DropdownOption } from "@/types/dropdownSettings";
@@ -145,12 +151,10 @@ export function Step4Route({ form }: { form: BookingForm }) {
);
}
function getStationOptions(options?: DropdownOption[]): DropdownOption[] {
return [...(options ?? [])].sort((a, b) => a.order - b.order);
}
function StationSelectOptions({
options,
excludeValue,
@@ -193,4 +197,4 @@ function StationSelectOptions({
))}
</>
);
}
}

View File

@@ -78,7 +78,9 @@ export function Step5CargoDetails({
selected={cargoType === "container"}
onClick={() => {
field.onChange("container");
form.setValue("freightType", "", { shouldDirty: true });
form.setValue("freightType", undefined, {
shouldDirty: true,
});
form.setValue("cargoWeight", "", { shouldDirty: true });
}}
>

View File

@@ -1,287 +0,0 @@
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
Building2,
Calendar,
Hash,
MapPin,
Package,
Ruler,
StickyNote,
Trash2,
Weight,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import {
getConsignmentById,
type ConsignmentStatus,
} from "./consignments.mock";
import {
Button,
Card,
} from "@edr/ui-common";
export default function ConsignmentDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const consignment = id ? getConsignmentById(id) : undefined;
if (!consignment) {
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: "Not found" },
]}
/>
<Card className="p-8 text-center">
<h1 className="text-2xl font-bold text-slate-900">
Consignment not found
</h1>
<p className="mt-2 text-sm text-muted-foreground">
The consignment you're looking for doesn't exist or has been removed.
</p>
<Link
to="/consignments"
className="mt-6 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition hover:bg-primary/90"
>
<ArrowLeft />
Back to Consignments
</Link>
</Card>
</div>
</div>
);
}
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Consignments", href: "/consignments" },
{ label: consignment.trackingNumber },
]}
/>
<Card className="p-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package />
</div>
<div>
<h1 className="flex items-center gap-3 text-3xl font-bold tracking-tight text-slate-900">
{consignment.trackingNumber}
{consignment.hazardous ? (
<span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-1 text-xs font-semibold text-red-700">
<AlertTriangle />
Hazardous
</span>
) : null}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>{consignment.customer}</span>
<span className="text-slate-300"></span>
<span>Booking {consignment.bookingReference}</span>
<span className="text-slate-300"></span>
<StatusBadge status={consignment.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<Button>Edit Consignment</Button>
</NewConsignmentPage>
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
onConfirm={() => navigate("/consignments")}
>
<Button variant="outline">
<Trash2 />
Remove
</Button>
</DeleteConsignmentDialog>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
<RouteEndpoint
label="Origin"
station={consignment.originStation}
/>
<ArrowRight className="text-primary" />
<RouteEndpoint
label="Destination"
station={consignment.destinationStation}
/>
</div>
</Card>
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Cargo">
<DetailRow
icon={<Package />}
label="Cargo Type"
value={consignment.cargoType}
/>
<DetailRow
icon={<Hash />}
label="Pieces"
value={String(consignment.pieces)}
/>
<DetailRow
icon={<Weight />}
label="Weight"
value={`${consignment.weightKg.toLocaleString()} kg`}
/>
<DetailRow
icon={<Ruler />}
label="Volume"
value={`${consignment.volumeM3}`}
/>
</DetailCard>
<DetailCard title="References & Schedule">
<DetailRow
icon={<Building2 />}
label="Customer"
value={consignment.customer}
/>
<DetailRow
icon={<Hash />}
label="Booking"
value={consignment.bookingReference}
/>
<DetailRow
icon={<Calendar />}
label="Created"
value={consignment.createdAt}
/>
<DetailRow
icon={<Calendar />}
label="Estimated Delivery"
value={consignment.estimatedDelivery}
/>
</DetailCard>
<DetailCard title="Description">
<div className="flex items-start gap-3 text-sm text-slate-700">
<Package />
<p className="leading-relaxed">{consignment.description}</p>
</div>
</DetailCard>
<DetailCard title="Special Handling">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote />
<p className="leading-relaxed">{consignment.specialHandling}</p>
</div>
</DetailCard>
</div>
</div>
</div>
);
}
function RouteEndpoint({
label,
station,
}: {
label: string;
station: string;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<MapPin />
</div>
<div>
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
{label}
</p>
<p className="text-lg font-semibold text-slate-900">{station}</p>
</div>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<Card className="p-6">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</Card>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-primary">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,429 +0,0 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertTriangle,
ArrowRight,
CheckCircle2,
Eye,
Filter,
MoreHorizontal,
Package,
Pencil,
Plus,
Search,
Trash2,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
import { consignments, type ConsignmentStatus } from "./consignments.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | ConsignmentStatus;
const FILTERS: FilterValue[] = [
"All",
"Pending",
"In Warehouse",
"In Transit",
"Delivered",
"Returned",
];
export default function ConsignmentsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return consignments.filter((c) => {
if (filter !== "All" && c.status !== filter) return false;
if (!q) return true;
return (
c.trackingNumber.toLowerCase().includes(q) ||
c.bookingReference.toLowerCase().includes(q) ||
c.customer.toLowerCase().includes(q) ||
c.originStation.toLowerCase().includes(q) ||
c.destinationStation.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const inTransitCount = consignments.filter(
(c) => c.status === "In Transit",
).length;
const deliveredCount = consignments.filter(
(c) => c.status === "Delivered",
).length;
const hazmatCount = consignments.filter((c) => c.hazardous).length;
const columns: ColumnDef<(typeof consignments)[number]>[] = [
{
id: "trackingNumber",
header: "Tracking #",
cell: ({ row }) => {
const c = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package />
</div>
<div>
<p className="flex items-center gap-2 font-medium text-slate-900">
{c.trackingNumber}
{c.hazardous ? (
<span
title="Hazardous"
className="inline-flex items-center gap-0.5 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700"
>
<AlertTriangle />
DG
</span>
) : null}
</p>
<p className="text-sm text-slate-500">{c.createdAt}</p>
</div>
</div>
);
},
},
{
accessorKey: "bookingReference",
header: "Booking",
},
{
accessorKey: "customer",
header: "Customer",
},
{
id: "route",
header: "Route",
cell: ({ row }) => (
<div className="flex items-center gap-2 text-sm text-slate-700">
<span>{row.original.originStation}</span>
<ArrowRight className="text-slate-400" />
<span>{row.original.destinationStation}</span>
</div>
),
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const c = row.original;
return (
<div className="text-sm text-slate-700">
<p>{c.cargoType}</p>
<p className="text-xs text-slate-500">
{c.pieces} pcs · {c.weightKg.toLocaleString()} kg · {c.volumeM3}{" "}
m³
</p>
</div>
);
},
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const consignment = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => navigate(`/consignments/${consignment.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<NewConsignmentPage
mode="edit"
consignment={{
trackingNumber: consignment.trackingNumber,
bookingId: consignment.bookingId,
bookingReference: consignment.bookingReference,
cargoType: consignment.cargoType,
description: consignment.description,
weightKg: consignment.weightKg,
volumeM3: consignment.volumeM3,
pieces: consignment.pieces,
hazardous: consignment.hazardous,
specialHandling: consignment.specialHandling,
status: consignment.status,
estimatedDelivery: consignment.estimatedDelivery,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewConsignmentPage>
<DropdownMenuSeparator />
<DeleteConsignmentDialog
trackingNumber={consignment.trackingNumber}
>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DeleteConsignmentDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Consignments" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Consignments
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Track cargo units and their handling status.
</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-2 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);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search consignments..."
className="pl-8!"
/>
</div>
<NewConsignmentPage>
<Button>
<Plus />
New Consignment
</Button>
</NewConsignmentPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Consignments</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{consignments.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Package />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Transit</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{inTransitCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Truck />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Delivered</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{deliveredCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<CheckCircle2 />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Hazardous</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{hazmatCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-red-100 text-red-600">
<AlertTriangle />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? consignments.length
: consignments.filter((c) => c.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Consignment List</CardTitle>
<CardDescription>
Cargo units and their current handling status.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
navigate(
`/consignments/${(row as (typeof consignments)[number]).id}`,
)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: ConsignmentStatus }) {
const styles: Record<ConsignmentStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
"In Warehouse": "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Returned: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,63 +0,0 @@
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 DeleteConsignmentDialogProps {
trackingNumber: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteConsignmentDialog({
trackingNumber,
onConfirm,
children,
}: DeleteConsignmentDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Remove consignment?
</DialogTitle>
<DialogDescription>
This will permanently remove consignment{" "}
<span className="font-semibold text-slate-900">
{trackingNumber}
</span>
. This action cannot be undone.
</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"
>
Remove
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,235 +0,0 @@
import type { ReactNode } from "react";
import { Calendar, Hash, Package, Ruler, Weight } 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 { bookings } from "../bookings/bookings.mock";
import type {
ConsignmentCargo,
ConsignmentStatus,
} from "./consignments.mock";
export interface ConsignmentFormData {
trackingNumber?: string;
bookingId?: number;
bookingReference?: string;
cargoType?: ConsignmentCargo;
description?: string;
weightKg?: number;
volumeM3?: number;
pieces?: number;
hazardous?: boolean;
specialHandling?: string;
status?: ConsignmentStatus;
estimatedDelivery?: string;
}
export interface NewConsignmentPageProps {
mode?: "create" | "edit";
consignment?: ConsignmentFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewConsignmentPage({
mode = "create",
consignment,
children,
}: NewConsignmentPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Consignment" : "New Consignment";
const description = isEdit
? "Update consignment details."
: "Register a new consignment under a freight booking.";
const submitLabel = isEdit ? "Save Changes" : "Create Consignment";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? (
<Button>{isEdit ? "Edit" : "New Consignment"}</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Tracking Number */}
<div className="space-y-2">
<Label>Tracking Number *</Label>
<div className="relative">
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={consignment?.trackingNumber ?? ""}
placeholder="e.g. CGM-0001"
className="pl-10"
/>
</div>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={consignment?.status ?? "Pending"}
className={selectClass}
>
<option>Pending</option>
<option>In Warehouse</option>
<option>In Transit</option>
<option>Delivered</option>
<option>Returned</option>
</select>
</div>
{/* Booking */}
<div className="space-y-2 md:col-span-2">
<Label>Freight Booking *</Label>
<select
defaultValue={consignment?.bookingId ?? ""}
className={selectClass}
>
<option value="" disabled>
Select freight booking
</option>
{bookings.map((b) => (
<option key={b.id} value={b.id}>
{b.reference} {b.customer} ({b.originStation} {" "}
{b.destinationStation})
</option>
))}
</select>
</div>
{/* Cargo Type */}
<div className="space-y-2">
<Label>Cargo Type *</Label>
<select
defaultValue={consignment?.cargoType ?? "Containerized"}
className={selectClass}
>
<option>Containerized</option>
<option>Bulk</option>
<option>Liquid</option>
<option>Refrigerated</option>
<option>Hazardous</option>
<option>General</option>
</select>
</div>
{/* Pieces */}
<div className="space-y-2">
<Label>Pieces</Label>
<div className="relative">
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={1}
defaultValue={consignment?.pieces ?? 1}
className="pl-10"
/>
</div>
</div>
{/* Weight */}
<div className="space-y-2">
<Label>Weight (kg)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.weightKg ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Volume */}
<div className="space-y-2">
<Label>Volume (m³)</Label>
<div className="relative">
<Ruler className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
step="0.1"
defaultValue={consignment?.volumeM3 ?? 0}
className="pl-10"
/>
</div>
</div>
{/* ETA */}
<div className="space-y-2">
<Label>Estimated Delivery</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={consignment?.estimatedDelivery ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Hazardous */}
<div className="space-y-2">
<Label>Hazardous Goods</Label>
<label className="flex h-10 items-center gap-2 rounded-md border border-slate-200 bg-white px-3 text-sm text-slate-700">
<input
type="checkbox"
defaultChecked={consignment?.hazardous ?? false}
className="h-4 w-4 rounded border-slate-300 text-[#10B981] focus:ring-[#10B981]/20"
/>
<span>Mark as hazardous (DG)</span>
</label>
</div>
{/* Description */}
<div className="space-y-2 md:col-span-2">
<Label>Description</Label>
<Textarea
defaultValue={consignment?.description ?? ""}
placeholder="Describe the consignment contents..."
/>
</div>
{/* Special Handling */}
<div className="space-y-2 md:col-span-2">
<Label>Special Handling</Label>
<Textarea
defaultValue={consignment?.specialHandling ?? ""}
placeholder="Any handling instructions..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,273 +0,0 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
Globe,
Loader2,
Mail,
MapPin,
Phone,
StickyNote,
Trash2,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
import type { CustomerStatus } from "@/types/customers";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: customer, isLoading, isError, error } = useCustomer(id);
const deleteMutation = useDeleteCustomer();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
/>
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
Loading customer
</div>
</div>
</div>
);
}
if (isError || !customer) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
<h1 className="text-2xl font-bold text-slate-900">
{isError ? "Failed to load customer" : "Customer not found"}
</h1>
<p className="mt-2 text-sm text-slate-500">
{isError && error instanceof Error
? error.message
: "The customer you're looking for doesn't exist or has been removed."}
</p>
<Link
to="/customers"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
</div>
</div>
</div>
);
}
const handleDelete = () => {
deleteMutation.mutate(customer.id, {
onSuccess: () => navigate("/customers"),
});
};
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: customer.name },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
<User className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{customer.name}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
<span className="text-slate-300"></span>
<span>{customer.company ?? "—"}</span>
<span className="text-slate-300"></span>
<StatusBadge status={customer.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setEditOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Customer
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Company Information">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Company Name"
value={customer.company ?? "—"}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="Customer Type"
value={customer.customerType}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="TIN Number"
value={customer.tinNumber ?? "—"}
/>
</DetailCard>
<DetailCard title="Contact">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Contact Person"
value={customer.name}
/>
<DetailRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={customer.email}
/>
<DetailRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={customer.phone}
/>
</DetailCard>
<DetailCard title="Location">
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="City"
value={customer.city ?? "—"}
/>
<DetailRow
icon={<Globe className="h-4 w-4" />}
label="Country"
value={customer.country ?? "—"}
/>
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="Address"
value={customer.address ?? "—"}
/>
</DetailCard>
<DetailCard title="Notes">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
</div>
</DetailCard>
</div>
</div>
<NewCustomerPage
mode="edit"
customer={customer}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteCustomerDialog
customerName={customer.name}
onConfirm={handleDelete}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,386 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Clock3,
Eye,
Filter,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
import type { Customer, CustomerStatus } from "@/types/customers";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "delete";
export default function CustomerPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
// Defer past the DropdownMenu close cycle so Radix doesn't leave
// `pointer-events: none` on <body>.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveCustomer(customer);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => setActiveDialog(null);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useCustomers();
const deleteMutation = useDeleteCustomer();
const customers = useMemo<Customer[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
c.phone.toLowerCase().includes(q),
);
}, [customers, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const activeCount = customers.filter((c) => c.status === "Active").length;
const pendingCount = customers.filter((c) => c.status === "Pending").length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<Customer>[] = [
{
accessorKey: "name",
header: "Customer",
cell: ({ row }) => {
const customer = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
<User className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{customer.name}</p>
<p className="text-sm text-slate-500">
{customer.company ?? "—"}
</p>
</div>
</div>
);
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.email}</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.phone}</span>
),
},
{
accessorKey: "customerType",
header: "Type",
cell: ({ row }) => (
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
{row.original.customerType}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const customer = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => navigate(`/customers/${customer.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("edit", customer)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", customer)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
<Card className="p-6 flex-row justify-between ">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Customers
</h1>
<p className="mt-1 text-sm text-secondary-foreground ">
Manage and monitor your customer records.
</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-2 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);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search customers..."
className="pl-8!"
/>
</div>
<NewCustomerPage>
<Button>
<Plus />
Add Customer
</Button>
</NewCustomerPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Customers"
value={customers.length}
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Accounts"
value={activeCount}
icon={<UserCheck className="h-5 w-5" />}
/>
<StatCard
title="Pending Requests"
value={pendingCount}
icon={<Clock3 className="h-5 w-5" />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load customers.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b ">
<div>
<CardTitle>Customer List</CardTitle>
<CardDescription>
Recent customer activities and records.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading customers
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={(row) => navigate(`/customers/${row.id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
unmount + pointer-events conflict). */}
{activeCustomer ? (
<>
<NewCustomerPage
key={`edit-${activeCustomer.id}`}
mode="edit"
customer={activeCustomer}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteCustomerDialog
key={`delete-${activeCustomer.id}`}
customerName={activeCustomer.name}
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent 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-primary text-white">
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,72 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Button,
} from "@edr/ui-common";
export interface DeleteCustomerDialogProps {
customerName: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteCustomerDialog({
customerName,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteCustomerDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete customer?
</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
from your records. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,223 +0,0 @@
import type { ReactNode } from "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 {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
} from "lucide-react";
export interface CustomerFormData {
companyName?: string;
customerType?: string;
contactPerson?: string;
email?: string;
phone?: string;
tinNumber?: string;
city?: string;
country?: string;
address?: string;
notes?: string;
}
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: CustomerFormData;
children?: ReactNode;
}
export default function NewCustomerPage({
mode = "create",
customer,
children,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Company Name */}
<div className="space-y-2">
<Label>Company Name *</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.companyName ?? ""}
placeholder="Enter company name"
className="pl-10"
/>
</div>
</div>
{/* Customer Type */}
<div className="space-y-2">
<Label>Customer Type *</Label>
<select
defaultValue={customer?.customerType ?? "Importer"}
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
>
<option>Importer</option>
<option>Exporter</option>
<option>Supplier</option>
</select>
</div>
{/* Contact Person */}
<div className="space-y-2">
<Label>Contact Person</Label>
<div className="relative">
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.contactPerson ?? ""}
placeholder="Enter contact person"
className="pl-10"
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label>Email *</Label>
<div className="relative">
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
type="email"
defaultValue={customer?.email ?? ""}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<Label>Phone</Label>
<div className="relative">
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.phone ?? ""}
placeholder="Enter phone"
className="pl-10"
/>
</div>
</div>
{/* TIN */}
<div className="space-y-2">
<Label>TIN Number</Label>
<div className="relative">
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.tinNumber ?? ""}
placeholder="Enter TIN number"
className="pl-10"
/>
</div>
</div>
{/* City */}
<div className="space-y-2">
<Label>City</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.city ?? ""}
placeholder="Enter city"
className="pl-10"
/>
</div>
</div>
{/* Country */}
<div className="space-y-2">
<Label>Country</Label>
<div className="relative">
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
defaultValue={customer?.country ?? ""}
placeholder="Enter country"
className="pl-10"
/>
</div>
</div>
{/* Address */}
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
<Textarea
defaultValue={customer?.address ?? ""}
placeholder="Enter address"
/>
</div>
{/* Notes */}
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
defaultValue={customer?.notes ?? ""}
placeholder="Additional notes..."
/>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,624 +0,0 @@
import type { ReactNode } from "react";
import { useState } from "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 {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
CreditCard,
Briefcase,
Users,
UserCircle,
StickyNote,
} from "lucide-react";
import { z } from "zod";
import { URL_CONSTANTS } from "@/constants/URLS";
import { customersService } from "@/services/customers.service";
import { useNavigate } from "react-router-dom";
export interface CustomerFormData {
firstName: string;
lastName: string;
email: string;
phone: string;
companyName: string;
companyEmail: string;
companyPhone: string;
companyLocation: string;
companyAddress: string;
contactPersonName: string;
contactPersonPhone: string;
tinNumber: string;
vatNumber: string;
fanNumber: string;
generalManagerName: string;
generalManagerEmail: string;
generalManagerPhone: string;
poaName?: string;
poaPhone?: string;
poaAddress?: string;
poaEmail?: string;
poaLocation?: string;
notes?: string;
}
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: Partial<CustomerFormData>;
children?: ReactNode;
}
export default function NewCustomerPage({
mode = "create",
customer,
children,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
const currentUser = JSON.parse(localStorage.getItem("currentUser")?? "{}");
console.log(currentUser)
const [formData, setFormData] = useState<CustomerFormData>({
firstName: currentUser?.name?.en?.split(" ")?.[0] ?? "",
lastName: currentUser?.name?.en?.split(" ")?.[1] ?? "",
email: currentUser?.email ?? "",
phone: currentUser?.phoneNumber ?? "",
companyName: customer?.companyName ?? "",
companyEmail: customer?.companyEmail ?? "",
companyPhone: customer?.companyPhone ?? "",
companyLocation: customer?.companyLocation ?? "",
companyAddress: customer?.companyAddress ?? "",
contactPersonName: customer?.contactPersonName ?? "",
contactPersonPhone: customer?.contactPersonPhone ?? "",
tinNumber: customer?.tinNumber ?? "",
vatNumber: customer?.vatNumber ?? "",
fanNumber: customer?.fanNumber ?? "",
generalManagerName: customer?.generalManagerName ?? "",
generalManagerEmail: customer?.generalManagerEmail ?? "",
generalManagerPhone: customer?.generalManagerPhone ?? "",
poaName: customer?.poaName ?? "",
poaPhone: customer?.poaPhone ?? "",
poaAddress: customer?.poaAddress ?? "",
poaEmail: customer?.poaEmail ?? "",
poaLocation: customer?.poaLocation ?? "",
notes: customer?.notes ?? "",
});
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const validateForm = (): boolean => {
const {
companyName,
companyEmail,
companyPhone,
companyLocation,
companyAddress,
contactPersonName,
contactPersonPhone,
tinNumber,
vatNumber,
fanNumber,
generalManagerName,
generalManagerEmail,
generalManagerPhone,
} = formData;
if (
!companyName ||
!companyEmail ||
!companyPhone ||
!companyLocation ||
!companyAddress ||
!contactPersonName ||
!contactPersonPhone ||
!tinNumber ||
!vatNumber ||
!fanNumber ||
!generalManagerName ||
!generalManagerEmail ||
!generalManagerPhone
) {
alert("Please fill all mandatory fields.");
return false;
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(companyEmail)) {
alert("Please enter a valid email address.");
return false;
}
if (!emailRegex.test(companyEmail)) {
alert("Please enter a valid company email address.");
return false;
}
if (!emailRegex.test(generalManagerEmail)) {
alert("Please enter a valid general manager email address.");
return false;
}
if (tinNumber.length !== 10 || !/^\d+$/.test(tinNumber)) {
alert("TIN must be exactly 10 digits.");
return false;
}
if (fanNumber.length !== 16 || !/^\d+$/.test(fanNumber)) {
alert("FAN must be exactly 16 digits.");
return false;
}
return true;
};
const handleSubmit = async () => {
if (!validateForm()) return;
setIsSubmitting(true);
try {
const apiUrl = `${import.meta.env.VITE_API_URL}/api${URL_CONSTANTS.CUSTOMERS.BASE}`;
// const response = await fetch(apiUrl, {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// },
// body: JSON.stringify({...formData, userId: currentUser?.id}),
// });
const response = await customersService.create({...formData, userId: currentUser?.id})
console.log(";;;;", response)
if(response){
// navigate("/")
window.navigation.reload();
}
if (!response) {
// throw new Error(data.message || `Failed to ${isEdit ? 'update' : 'create'} customer`);
}
// console.log(`Customer ${isEdit ? 'updated' : 'created'}:`, data);
alert(`Customer ${isEdit ? 'updated' : 'created'} successfully!`);
// Close dialog or reset form here if needed
} catch (error) {
console.error('Error:', error);
alert(error instanceof Error ? error.message : `Failed to ${isEdit ? 'update' : 'create'} customer`);
} finally {
setIsSubmitting(false);
}
};
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-4xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Personal Information Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3">
<User className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Personal Information</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* First Name */}
<div className="space-y-2">
<Label>First Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="firstName"
value={formData.firstName}
onChange={handleChange}
placeholder="Enter first name"
className="pl-10"
/>
</div>
</div>
{/* Last Name */}
<div className="space-y-2">
<Label>Last Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="lastName"
value={formData.lastName}
onChange={handleChange}
placeholder="Enter last name"
className="pl-10"
/>
</div>
</div>
{/* Email */}
<div className="space-y-2">
<Label>Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="Enter email"
className="pl-10"
/>
</div>
</div>
{/* Phone */}
<div className="space-y-2">
<Label>Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="phone"
value={formData.phone}
onChange={handleChange}
placeholder="Enter phone number"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Company Information Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Building2 className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Company Information</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* Company Name */}
<div className="space-y-2">
<Label>Company Name <span className="text-red-500">*</span></Label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyName"
value={formData.companyName}
onChange={handleChange}
placeholder="Enter company name"
className="pl-10"
/>
</div>
</div>
{/* Company Email */}
<div className="space-y-2">
<Label>Company Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="companyEmail"
value={formData.companyEmail}
onChange={handleChange}
placeholder="Enter company email"
className="pl-10"
/>
</div>
</div>
{/* Company Phone */}
<div className="space-y-2">
<Label>Company Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyPhone"
value={formData.companyPhone}
onChange={handleChange}
placeholder="Enter company phone"
className="pl-10"
/>
</div>
</div>
{/* Company Location */}
<div className="space-y-2">
<Label>Company Location <span className="text-red-500">*</span></Label>
<div className="relative">
<MapPin className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="companyLocation"
value={formData.companyLocation}
onChange={handleChange}
placeholder="Enter company location"
className="pl-10"
/>
</div>
</div>
{/* Company Address */}
<div className="space-y-2 md:col-span-2">
<Label>Company Address <span className="text-red-500">*</span></Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Textarea
name="companyAddress"
value={formData.companyAddress}
onChange={handleChange}
placeholder="Enter company address"
className="pl-10 resize-none"
rows={2}
/>
</div>
</div>
</div>
</div>
{/* Tax & Registration Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<CreditCard className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Tax & Registration Numbers</h3>
</div>
<div className="grid gap-5 md:grid-cols-3">
{/* TIN Number */}
<div className="space-y-2">
<Label>TIN Number <span className="text-red-500">*</span></Label>
<Input
name="tinNumber"
value={formData.tinNumber}
onChange={handleChange}
placeholder="10-digit TIN"
maxLength={10}
/>
</div>
{/* VAT Number */}
<div className="space-y-2">
<Label>VAT Number <span className="text-red-500">*</span></Label>
<Input
name="vatNumber"
value={formData.vatNumber}
onChange={handleChange}
placeholder="Enter VAT number"
/>
</div>
{/* FAN Number */}
<div className="space-y-2">
<Label>FAN Number <span className="text-red-500">*</span></Label>
<Input
name="fanNumber"
value={formData.fanNumber}
onChange={handleChange}
placeholder="16-digit FAN"
maxLength={16}
/>
</div>
</div>
</div>
{/* General Manager Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Briefcase className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">General Manager</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* General Manager Name */}
<div className="space-y-2">
<Label>General Manager Name <span className="text-red-500">*</span></Label>
<div className="relative">
<UserCircle className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="generalManagerName"
value={formData.generalManagerName}
onChange={handleChange}
placeholder="Enter general manager name"
className="pl-10"
/>
</div>
</div>
{/* General Manager Email */}
<div className="space-y-2">
<Label>General Manager Email <span className="text-red-500">*</span></Label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="email"
name="generalManagerEmail"
value={formData.generalManagerEmail}
onChange={handleChange}
placeholder="Enter general manager email"
className="pl-10"
/>
</div>
</div>
{/* General Manager Phone */}
<div className="space-y-2">
<Label>General Manager Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="generalManagerPhone"
value={formData.generalManagerPhone}
onChange={handleChange}
placeholder="Enter general manager phone"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Contact Person Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<Users className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Contact Person</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* Contact Person Name */}
<div className="space-y-2">
<Label>Contact Person Name <span className="text-red-500">*</span></Label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="contactPersonName"
value={formData.contactPersonName}
onChange={handleChange}
placeholder="Enter contact person name"
className="pl-10"
/>
</div>
</div>
{/* Contact Person Phone */}
<div className="space-y-2">
<Label>Contact Person Phone <span className="text-red-500">*</span></Label>
<div className="relative">
<Phone className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
name="contactPersonPhone"
value={formData.contactPersonPhone}
onChange={handleChange}
placeholder="Enter contact person phone"
className="pl-10"
/>
</div>
</div>
</div>
</div>
{/* Power of Attorney Section (Optional) */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<FileText className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Power of Attorney (Optional)</h3>
</div>
<div className="grid gap-5 md:grid-cols-2">
{/* POA Name */}
<div className="space-y-2">
<Label>PoA Name</Label>
<Input
name="poaName"
value={formData.poaName ?? ""}
onChange={handleChange}
placeholder="Enter PoA name"
/>
</div>
{/* POA Phone */}
<div className="space-y-2">
<Label>PoA Phone</Label>
<Input
name="poaPhone"
value={formData.poaPhone ?? ""}
onChange={handleChange}
placeholder="Enter PoA phone"
/>
</div>
{/* POA Email */}
<div className="space-y-2">
<Label>PoA Email</Label>
<Input
type="email"
name="poaEmail"
value={formData.poaEmail ?? ""}
onChange={handleChange}
placeholder="Enter PoA email"
/>
</div>
{/* POA Location */}
<div className="space-y-2">
<Label>PoA Location</Label>
<Input
name="poaLocation"
value={formData.poaLocation ?? ""}
onChange={handleChange}
placeholder="Enter PoA location"
/>
</div>
{/* POA Address */}
<div className="space-y-2 md:col-span-2">
<Label>PoA Address</Label>
<Textarea
name="poaAddress"
value={formData.poaAddress ?? ""}
onChange={handleChange}
placeholder="Enter PoA address"
rows={2}
/>
</div>
</div>
</div>
{/* Notes Section */}
<div className="md:col-span-2">
<div className="flex items-center gap-2 mb-3 mt-2">
<StickyNote className="h-5 w-5 text-[#10B981]" />
<h3 className="font-semibold text-lg">Additional Notes</h3>
</div>
<Textarea
name="notes"
value={formData.notes ?? ""}
onChange={handleChange}
placeholder="Add any additional notes about the customer..."
rows={3}
/>
</div>
</div>
<div className="flex justify-end gap-3 mt-4">
<Button variant="outline">Cancel</Button>
<Button
className="bg-[#10B981] text-white hover:bg-[#10B981]/90"
onClick={handleSubmit}
disabled={isSubmitting}
>
{isSubmitting ? "Submitting..." : submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,273 +0,0 @@
import { useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
ArrowLeft,
Building2,
FileText,
Globe,
Loader2,
Mail,
MapPin,
Phone,
StickyNote,
Trash2,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomer, useDeleteCustomer } from "@/hooks/useCustomers";
import type { CustomerStatus } from "@/types/customers";
export default function CustomerDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: customer, isLoading, isError, error } = useCustomer(id);
const deleteMutation = useDeleteCustomer();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
if (isLoading) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[{ label: "Customers", href: "/customers" }, { label: "…" }]}
/>
<div className="flex items-center justify-center rounded-3xl bg-white p-12 text-sm text-slate-500 shadow-sm">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-[#10B981]" />
Loading customer
</div>
</div>
</div>
);
}
if (isError || !customer) {
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: "Not found" },
]}
/>
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
<AlertCircle className="mx-auto mb-3 h-6 w-6 text-red-500" />
<h1 className="text-2xl font-bold text-slate-900">
{isError ? "Failed to load customer" : "Customer not found"}
</h1>
<p className="mt-2 text-sm text-slate-500">
{isError && error instanceof Error
? error.message
: "The customer you're looking for doesn't exist or has been removed."}
</p>
<Link
to="/customers"
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
<ArrowLeft className="h-4 w-4" />
Back to Customers
</Link>
</div>
</div>
</div>
);
}
const handleDelete = () => {
deleteMutation.mutate(customer.id, {
onSuccess: () => navigate("/customers"),
});
};
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-5xl space-y-6">
<Breadcrumbs
items={[
{ label: "Customers", href: "/customers" },
{ label: customer.name },
]}
/>
{/* Header */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#10B981] text-white">
<User className="h-8 w-8" />
</div>
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
{customer.name}
</h1>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
<span className="font-mono text-xs">#{customer.id.slice(0, 8)}</span>
<span className="text-slate-300"></span>
<span>{customer.company ?? "—"}</span>
<span className="text-slate-300"></span>
<StatusBadge status={customer.status} />
</div>
</div>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setEditOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#10B981]/90"
>
Edit Customer
</button>
<button
type="button"
onClick={() => setDeleteOpen(true)}
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
</div>
</div>
</div>
{/* Detail grid */}
<div className="grid gap-6 md:grid-cols-2">
<DetailCard title="Company Information">
<DetailRow
icon={<Building2 className="h-4 w-4" />}
label="Company Name"
value={customer.company ?? "—"}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="Customer Type"
value={customer.customerType}
/>
<DetailRow
icon={<FileText className="h-4 w-4" />}
label="TIN Number"
value={customer.tinNumber ?? "—"}
/>
</DetailCard>
<DetailCard title="Contact">
<DetailRow
icon={<User className="h-4 w-4" />}
label="Contact Person"
value={customer.name}
/>
<DetailRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={customer.email}
/>
<DetailRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={customer.phone}
/>
</DetailCard>
<DetailCard title="Location">
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="City"
value={customer.city ?? "—"}
/>
<DetailRow
icon={<Globe className="h-4 w-4" />}
label="Country"
value={customer.country ?? "—"}
/>
<DetailRow
icon={<MapPin className="h-4 w-4" />}
label="Address"
value={customer.address ?? "—"}
/>
</DetailCard>
<DetailCard title="Notes">
<div className="flex items-start gap-3 text-sm text-slate-700">
<StickyNote className="mt-0.5 h-4 w-4 text-[#10B981]" />
<p className="leading-relaxed">{customer.notes ?? "—"}</p>
</div>
</DetailCard>
</div>
</div>
<NewCustomerPage
mode="edit"
customer={customer}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteCustomerDialog
customerName={customer.name}
onConfirm={handleDelete}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</div>
);
}
function DetailCard({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">{title}</h2>
<div className="mt-4 space-y-3">{children}</div>
</div>
);
}
function DetailRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,386 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
Clock3,
Eye,
Filter,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
User,
UserCheck,
Users,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewCustomerPage from "./NewCustomerPage";
import DeleteCustomerDialog from "./DeleteCustomerDialog";
import { useCustomers, useDeleteCustomer } from "@/hooks/useCustomers";
import type { Customer, CustomerStatus } from "@/types/customers";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type ActiveDialog = "edit" | "delete";
export default function CustomerPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [activeDialog, setActiveDialog] = useState<ActiveDialog | null>(null);
const [activeCustomer, setActiveCustomer] = useState<Customer | null>(null);
const openDialogFor = (dialog: ActiveDialog, customer: Customer) => {
// Defer past the DropdownMenu close cycle so Radix doesn't leave
// `pointer-events: none` on <body>.
requestAnimationFrame(() => {
requestAnimationFrame(() => {
document.body.style.pointerEvents = "";
setActiveCustomer(customer);
setActiveDialog(dialog);
});
});
};
const closeDialog = () => setActiveDialog(null);
useEffect(() => {
const id = requestAnimationFrame(() => {
if (document.body.style.pointerEvents === "none") {
document.body.style.pointerEvents = "";
}
});
return () => cancelAnimationFrame(id);
}, [activeDialog]);
const { data, isLoading, isError, error } = useCustomers();
const deleteMutation = useDeleteCustomer();
const customers = useMemo<Customer[]>(
() => (Array.isArray(data) ? data : []),
[data],
);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return customers;
return customers.filter(
(c) =>
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
(c.company ?? "").toLowerCase().includes(q) ||
c.phone.toLowerCase().includes(q),
);
}, [customers, query]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const activeCount = customers.filter((c) => c.status === "Active").length;
const pendingCount = customers.filter((c) => c.status === "Pending").length;
const status: "loading" | "error" | "success" = isLoading
? "loading"
: isError
? "error"
: "success";
const columns: ColumnDef<Customer>[] = [
{
accessorKey: "name",
header: "Customer",
cell: ({ row }) => {
const customer = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-secondary text-secondary-foreground border">
<User className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{customer.name}</p>
<p className="text-sm text-slate-500">
{customer.company ?? "—"}
</p>
</div>
</div>
);
},
},
{
accessorKey: "email",
header: "Email",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.email}</span>
),
},
{
accessorKey: "phone",
header: "Phone",
cell: ({ row }) => (
<span className="text-sm text-slate-700">{row.original.phone}</span>
),
},
{
accessorKey: "customerType",
header: "Type",
cell: ({ row }) => (
<span className="inline-flex rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-600">
{row.original.customerType}
</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const customer = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() => navigate(`/customers/${customer.id}`)}
>
<Eye />
View
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => openDialogFor("edit", customer)}
>
<Pencil />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => openDialogFor("delete", customer)}
variant="destructive"
>
<Trash2 />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
<Card className="p-6 flex-row justify-between ">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Customers
</h1>
<p className="mt-1 text-sm text-secondary-foreground ">
Manage and monitor your customer records.
</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-2 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);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search customers..."
className="pl-8!"
/>
</div>
<NewCustomerPage>
<Button>
<Plus />
Add Customer
</Button>
</NewCustomerPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Customers"
value={customers.length}
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Accounts"
value={activeCount}
icon={<UserCheck className="h-5 w-5" />}
/>
<StatCard
title="Pending Requests"
value={pendingCount}
icon={<Clock3 className="h-5 w-5" />}
/>
</div>
{isError ? (
<Card>
<CardContent className="flex items-center gap-3 py-6 text-sm text-red-600">
<AlertCircle className="h-5 w-5" />
Failed to load customers.{" "}
{error instanceof Error ? error.message : "Unknown error."}
</CardContent>
</Card>
) : null}
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b ">
<div>
<CardTitle>Customer List</CardTitle>
<CardDescription>
Recent customer activities and records.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
{isLoading ? (
<div className="flex items-center justify-center py-12 text-sm text-slate-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-primary" />
Loading customers
</div>
) : (
<DataTable
columns={columns}
data={paginatedData}
status={status}
onRowClick={(row) => navigate(`/customers/${row.id}`)}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
)}
</CardContent>
</Card>
</div>
{/* Hoisted controlled dialogs (avoid Radix nested DropdownMenu+Dialog
unmount + pointer-events conflict). */}
{activeCustomer ? (
<>
<NewCustomerPage
key={`edit-${activeCustomer.id}`}
mode="edit"
customer={activeCustomer}
open={activeDialog === "edit"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
<DeleteCustomerDialog
key={`delete-${activeCustomer.id}`}
customerName={activeCustomer.name}
onConfirm={() => deleteMutation.mutate(activeCustomer.id)}
open={activeDialog === "delete"}
onOpenChange={(next) => (next ? null : closeDialog())}
/>
</>
) : null}
</div>
);
}
function StatCard({
title,
value,
icon,
}: {
title: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent 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-primary text-white">
{icon}
</div>
</CardContent>
</Card>
);
}
function StatusBadge({ status }: { status: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,72 +0,0 @@
import { useState, type ReactNode } from "react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
Button,
} from "@edr/ui-common";
export interface DeleteCustomerDialogProps {
customerName: string;
onConfirm?: () => void;
children?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
export default function DeleteCustomerDialog({
customerName,
onConfirm,
children,
open: openProp,
onOpenChange,
}: DeleteCustomerDialogProps) {
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
{children ? <DialogTrigger asChild>{children}</DialogTrigger> : null}
<DialogContent>
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Delete customer?
</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-semibold text-slate-900">{customerName}</span>{" "}
from your records. This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
onClick={onConfirm}
className="bg-red-600 text-white hover:bg-red-700"
>
Delete
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,384 +0,0 @@
import { useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
Input,
Label,
Button,
Textarea,
SmartFileInput,
} from "@edr/ui-common";
import {
Building2,
Mail,
Phone,
User,
Globe,
MapPin,
FileText,
} from "lucide-react";
import {
useCreateCustomer,
useUpdateCustomer,
} from "@/hooks/useCustomers";
import { getFileUploadSettingByCode } from "@/services/fileUploadSettings.service";
import { FILE_SETTINGS } from "@/constants/FILE_SETTINGS";
import type {
CreateCustomerDto,
Customer,
CustomerStatus,
CustomerType,
} from "@/types/customers";
const CUSTOMER_TYPES: CustomerType[] = ["Importer", "Exporter", "Supplier"];
const CUSTOMER_STATUSES: CustomerStatus[] = ["Active", "Pending", "Inactive"];
export interface NewCustomerPageProps {
mode?: "create" | "edit";
customer?: Customer;
children?: ReactNode;
/** Controlled open. When omitted, the dialog manages its own open state. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
type FormState = {
name: string;
email: string;
phone: string;
company: string;
customerType: CustomerType;
status: CustomerStatus;
tinNumber: string;
city: string;
country: string;
address: string;
notes: string;
};
const emptyForm = (): FormState => ({
name: "",
email: "",
phone: "",
company: "",
customerType: "Importer",
status: "Active",
tinNumber: "",
city: "",
country: "",
address: "",
notes: "",
});
const fromCustomer = (c: Customer): FormState => ({
name: c.name ?? "",
email: c.email ?? "",
phone: c.phone ?? "",
company: c.company ?? "",
customerType: c.customerType ?? "Importer",
status: c.status ?? "Active",
tinNumber: c.tinNumber ?? "",
city: c.city ?? "",
country: c.country ?? "",
address: c.address ?? "",
notes: c.notes ?? "",
});
export default function NewCustomerPage({
mode = "create",
customer,
children,
open: openProp,
onOpenChange,
}: NewCustomerPageProps = {}) {
const isEdit = mode === "edit";
const isControlled = openProp !== undefined;
const [internalOpen, setInternalOpen] = useState(false);
const open = isControlled ? openProp : internalOpen;
const setOpen = (next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};
const [form, setForm] = useState<FormState>(
customer ? fromCustomer(customer) : emptyForm(),
);
const [error, setError] = useState<string | null>(null);
const [files, setFiles] = useState<Record<string, File | File[] | null>>({});
// Reset form whenever the dialog opens with a different customer.
useEffect(() => {
if (open) {
setForm(customer ? fromCustomer(customer) : emptyForm());
setError(null);
}
}, [open, customer]);
const { data: customerRegistrationFiles } = useQuery(
getFileUploadSettingByCode.queryOptions({
input: FILE_SETTINGS.CUSTOMER_REGISTRATION,
}),
);
const createMutation = useCreateCustomer();
const updateMutation = useUpdateCustomer();
const pending = createMutation.isPending || updateMutation.isPending;
const set = <K extends keyof FormState>(key: K, value: FormState[K]) =>
setForm((prev) => ({ ...prev, [key]: value }));
const handleSubmit = () => {
setError(null);
if (!form.name.trim() || !form.email.trim() || !form.phone.trim()) {
setError("Name, email, and phone are required.");
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
setError("Please enter a valid email address.");
return;
}
const payload: CreateCustomerDto = {
name: form.name.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
customerType: form.customerType,
status: form.status,
company: form.company.trim() || undefined,
tinNumber: form.tinNumber.trim() || undefined,
city: form.city.trim() || undefined,
country: form.country.trim() || undefined,
address: form.address.trim() || undefined,
notes: form.notes.trim() || undefined,
};
const onDone = () => {
setOpen(false);
if (!isEdit) setForm(emptyForm());
};
const onError = (err: unknown) => {
setError(
err instanceof Error
? err.message
: "Something went wrong. Try again.",
);
};
if (isEdit && customer) {
updateMutation.mutate(
{ id: customer.id, dto: payload },
{ onSuccess: onDone, onError },
);
} else {
createMutation.mutate(payload, { onSuccess: onDone, onError });
}
};
const title = isEdit ? "Edit Customer" : "New Customer";
const description = isEdit
? "Update existing customer information."
: "Create and manage customer information.";
const submitLabel = isEdit ? "Save Changes" : "Create Customer";
return (
<Dialog open={open} onOpenChange={setOpen}>
{!isControlled ? (
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Customer"}</Button>}
</DialogTrigger>
) : null}
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl!">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
<Field label="Company Name">
<Building2 className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.company}
onChange={(e) => set("company", e.target.value)}
placeholder="Enter company name"
className="pl-10"
/>
</Field>
<div className="space-y-2">
<Label>Customer Type *</Label>
<select
value={form.customerType}
onChange={(e) =>
set("customerType", e.target.value as CustomerType)
}
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
>
{CUSTOMER_TYPES.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
<Field label="Contact Person *">
<User className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="Enter contact person"
className="pl-10"
/>
</Field>
<Field label="Email *">
<Mail className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
type="email"
value={form.email}
onChange={(e) => set("email", e.target.value)}
placeholder="Enter email"
className="pl-10"
/>
</Field>
<Field label="Phone *">
<Phone className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.phone}
onChange={(e) => set("phone", e.target.value)}
placeholder="Enter phone"
className="pl-10"
/>
</Field>
<Field label="TIN Number">
<FileText className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.tinNumber}
onChange={(e) => set("tinNumber", e.target.value)}
placeholder="Enter TIN number"
className="pl-10"
/>
</Field>
<Field label="City">
<MapPin className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.city}
onChange={(e) => set("city", e.target.value)}
placeholder="Enter city"
className="pl-10"
/>
</Field>
<Field label="Country">
<Globe className="absolute left-3 top-3.5 h-4 w-4 text-muted-foreground" />
<Input
value={form.country}
onChange={(e) => set("country", e.target.value)}
placeholder="Enter country"
className="pl-10"
/>
</Field>
<div className="space-y-2">
<Label>Status</Label>
<select
value={form.status}
onChange={(e) => set("status", e.target.value as CustomerStatus)}
className="flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20"
>
{CUSTOMER_STATUSES.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Address</Label>
<Textarea
value={form.address}
onChange={(e) => set("address", e.target.value)}
placeholder="Enter address"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label>Notes</Label>
<Textarea
value={form.notes}
onChange={(e) => set("notes", e.target.value)}
placeholder="Additional notes..."
/>
</div>
</div>
{customerRegistrationFiles ? (
<div>
<SmartFileInput
file={customerRegistrationFiles}
value={files}
onChange={setFiles}
/>
</div>
) : null}
{error ? (
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</p>
) : null}
<div className="flex justify-end gap-3">
<DialogClose asChild>
<Button variant="outline" disabled={pending}>
Cancel
</Button>
</DialogClose>
<Button
type="button"
onClick={handleSubmit}
disabled={pending}
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
>
{pending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
submitLabel
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
function Field({
label,
children,
}: {
label: string;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label>{label}</Label>
<div className="relative">{children}</div>
</div>
);
}

View File

@@ -1,110 +0,0 @@
export type CustomerStatus = "Active" | "Pending" | "Inactive";
export type CustomerType = "Importer" | "Exporter" | "Supplier";
export interface Customer {
id: number;
name: string;
email: string;
company: string;
status: CustomerStatus;
customerType: CustomerType;
phone: string;
tinNumber: string;
city: string;
country: string;
address: string;
notes: string;
}
const seedCustomers: Customer[] = [
{
id: 1,
name: "Abel Tesfaye",
email: "abel@example.com",
company: "Addis Logistics",
status: "Active",
customerType: "Importer",
phone: "+251 911 234 567",
tinNumber: "0012345678",
city: "Addis Ababa",
country: "Ethiopia",
address: "Bole Road, Sub-City 03, Building 17",
notes: "Top-tier importer. Prefers weekly invoicing.",
},
{
id: 2,
name: "Sara Bekele",
email: "sara@example.com",
company: "Blue Nile Trading",
status: "Pending",
customerType: "Exporter",
phone: "+251 922 345 678",
tinNumber: "0023456789",
city: "Dire Dawa",
country: "Ethiopia",
address: "Industrial Park, Zone B, Warehouse 4",
notes: "Awaiting compliance documents.",
},
{
id: 3,
name: "Henok Alemu",
email: "henok@example.com",
company: "Ethio Freight",
status: "Inactive",
customerType: "Supplier",
phone: "+251 933 456 789",
tinNumber: "0034567890",
city: "Djibouti City",
country: "Djibouti",
address: "Port Quarter, Avenue 26, Block 9",
notes: "Account paused since last quarter.",
},
];
const extras: Array<{ name: string; company: string; city: string; country: string }> = [
{ name: "Yohannes Girma", company: "Habesha Imports", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Meron Asfaw", company: "Sheba Trading", city: "Adama", country: "Ethiopia" },
{ name: "Daniel Kebede", company: "Awash Cargo", city: "Hawassa", country: "Ethiopia" },
{ name: "Liya Tadesse", company: "Lalibela Logistics", city: "Bahir Dar", country: "Ethiopia" },
{ name: "Samuel Worku", company: "Rift Valley Freight", city: "Mekelle", country: "Ethiopia" },
{ name: "Hanna Mulugeta", company: "Simien Exports", city: "Gondar", country: "Ethiopia" },
{ name: "Bereket Hailu", company: "Omo River Co.", city: "Jimma", country: "Ethiopia" },
{ name: "Tigist Wolde", company: "Tana Shipping", city: "Dessie", country: "Ethiopia" },
{ name: "Kalkidan Mesfin", company: "Coffee Belt Traders", city: "Addis Ababa", country: "Ethiopia" },
{ name: "Nahom Solomon", company: "Highland Freight", city: "Harar", country: "Ethiopia" },
{ name: "Ali Mohamed", company: "Red Sea Cargo", city: "Djibouti City", country: "Djibouti" },
{ name: "Fatima Hassan", company: "Gulf Logistics", city: "Tadjoura", country: "Djibouti" },
{ name: "Omar Ibrahim", company: "Bab-el-Mandeb Trading", city: "Ali Sabieh", country: "Djibouti" },
{ name: "Amina Said", company: "Horn of Africa Imports", city: "Dikhil", country: "Djibouti" },
{ name: "Yusuf Abdulahi", company: "Saharan Exports", city: "Obock", country: "Djibouti" },
{ name: "Selam Negash", company: "Equator Freight", city: "Arba Minch", country: "Ethiopia" },
{ name: "Mikias Lemma", company: "Gibe Trading", city: "Sodo", country: "Ethiopia" },
];
const statuses: CustomerStatus[] = ["Active", "Pending", "Inactive"];
const types: CustomerType[] = ["Importer", "Exporter", "Supplier"];
const generated: Customer[] = extras.map((entry, i) => {
const id = seedCustomers.length + i + 1;
return {
id,
name: entry.name,
email: `${entry.name.toLowerCase().replace(/\s+/g, ".")}@example.com`,
company: entry.company,
status: statuses[i % statuses.length] as CustomerStatus,
customerType: types[i % types.length] as CustomerType,
phone: `+251 9${String(40 + i).padStart(2, "0")} ${String(100 + i * 13).slice(0, 3)} ${String(200 + i * 17).slice(0, 3)}`,
tinNumber: String(40000000 + i * 12345).padStart(10, "0"),
city: entry.city,
country: entry.country,
address: `Block ${i + 1}, Street ${10 + i}, Quarter ${(i % 5) + 1}`,
notes: `Mock customer #${id}.`,
};
});
export const customers: Customer[] = [...seedCustomers, ...generated];
export function getCustomerById(id: number | string): Customer | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return customers.find((c) => c.id === numericId);
}

View File

@@ -1,676 +0,0 @@
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";
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";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
} from "@edr/ui-common";
const BRAND = "#10B981";
const BRAND_LIGHT = "#6EE7B7";
const BRAND_LIGHTER = "#D1FAE5";
const STATUS_PALETTE: Record<string, string> = {
Pending: "#d97706",
Confirmed: "#0ea5e9",
"In Transit": "#6366f1",
Delivered: "#059669",
Cancelled: "#dc2626",
};
export default function DashboardPage() {
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,
);
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),
}));
}, []);
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),
}));
}, []);
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,
}));
}, []);
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);
}, []);
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),
}));
}, []);
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);
}, []);
const recentShipments = useMemo(
() => [...shipments].slice(-5).reverse(),
[],
);
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Dashboard" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Freight Dashboard
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Operational overview · today · all corridors
</p>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs">
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Live
</span>
<span className="rounded-full bg-primary px-3 py-1 text-primary-foreground">
Last 30 days
</span>
</div>
</Card>
<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 />}
/>
<KpiCard
label="Active Bookings"
value={String(
bookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
).length,
)}
delta="+5.1%"
trend="up"
icon={<Package />}
/>
<KpiCard
label="Active Shipments"
value={String(activeShipments)}
delta="-2.3%"
trend="down"
icon={<Truck />}
/>
<KpiCard
label="On-time Rate"
value={`${onTimeRate}%`}
delta="+1.8%"
trend="up"
icon={<CheckCircle2 />}
/>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<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>
<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>
<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: "#10B98114" }}
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>
<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: "#10B98114" }}
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="Train 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>
<Card className="p-6">
<CardHeader className="flex flex-row items-center justify-between px-0">
<div>
<CardTitle>Recent Shipments</CardTitle>
<CardDescription>Latest activity</CardDescription>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-xs font-medium text-primary transition hover:underline"
>
View all
<ArrowRight />
</Link>
</CardHeader>
<CardContent className="px-0">
<ul className="space-y-3">
{recentShipments.map((s) => (
<li
key={s.id}
className="flex items-center justify-between gap-3 rounded-2xl border p-3"
>
<div className="flex items-center gap-3 overflow-hidden">
<CircleDot
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>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-4">
<SummaryCard
label="Customers"
value={String(customers.length)}
href="/customers"
icon={<Users />}
/>
<SummaryCard
label="Consignments"
value={String(consignments.length)}
href="/consignments"
icon={<Package />}
/>
<SummaryCard
label="Trains in Fleet"
value={String(trains.length)}
href="/trains"
icon={<Truck />}
/>
<SummaryCard
label="Open Invoices"
value={String(
invoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
).length,
)}
href="/billing"
icon={<DollarSign />}
/>
</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 (
<Card>
<CardContent 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 />
{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-primary text-primary-foreground">
{icon}
</div>
</CardContent>
</Card>
);
}
function ChartCard({
title,
subtitle,
children,
className,
}: {
title: string;
subtitle?: string;
children: React.ReactNode;
className?: string;
}) {
return (
<Card className={className}>
<CardHeader>
<CardTitle>{title}</CardTitle>
{subtitle ? <CardDescription>{subtitle}</CardDescription> : null}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
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-xl border bg-card px-4 py-4 text-card-foreground shadow-xs transition hover:bg-accent"
>
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary text-primary-foreground">
{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="text-slate-400" />
</Link>
);
}

View File

@@ -1,5 +1,4 @@
import { bookings } from "../bookings/bookings.mock";
import { consignments } from "../consignments/consignments.mock";
export type DocumentType =
| "Bill of Lading"
@@ -80,55 +79,59 @@ function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
return `${slug}-${ref.toLowerCase()}.${format.toLowerCase()}`;
}
export const documents: DocumentRecord[] = Array.from({ length: 24 }, (_, i) => {
const id = i + 1;
const type = types[i % types.length] as DocumentType;
const format = formats[i % formats.length] as DocumentFormat;
const status = statuses[i % statuses.length] as DocumentStatus;
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
export const documents: DocumentRecord[] = Array.from(
{ length: 24 },
(_, i) => {
const id = i + 1;
const type = types[i % types.length] as DocumentType;
const format = formats[i % formats.length] as DocumentFormat;
const status = statuses[i % statuses.length] as DocumentStatus;
const uploadedAt = new Date(2026, 4, 1 + (i % 14));
const linkPick = i % 3;
let linkedType: DocumentLinkType;
let linkedReference: string;
if (linkPick === 0) {
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
linkedType = "Booking";
linkedReference = booking.reference;
} else if (linkPick === 1) {
const consignment = consignments[
i % consignments.length
] as (typeof consignments)[number];
linkedType = "Consignment";
linkedReference = consignment.trackingNumber;
} else {
linkedType = "Invoice";
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
}
const linkPick = i % 3;
let linkedType: DocumentLinkType;
let linkedReference: string;
if (linkPick === 0) {
const booking = bookings[
i % bookings.length
] as (typeof bookings)[number];
linkedType = "Booking";
linkedReference = booking.reference;
} else if (linkPick === 1) {
linkedType = "Consignment";
linkedReference = "s";
} else {
linkedType = "Invoice";
linkedReference = `INV-2026-${String(id).padStart(4, "0")}`;
}
const name = fileNameFor(type, linkedReference, format);
const name = fileNameFor(type, linkedReference, format);
return {
id,
name,
type,
format,
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
linkedType,
linkedReference,
uploadedBy: uploaders[i % uploaders.length] as string,
uploadedAt: uploadedAt.toISOString().slice(0, 10),
status,
notes:
i % 3 === 0
? "Original signed copy."
: i % 3 === 1
? "Scanned from physical document."
: "Generated by system.",
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
};
});
return {
id,
name,
type,
format,
sizeBytes: 50_000 + ((i * 73_421) % 4_000_000),
linkedType,
linkedReference,
uploadedBy: uploaders[i % uploaders.length] as string,
uploadedAt: uploadedAt.toISOString().slice(0, 10),
status,
notes:
i % 3 === 0
? "Original signed copy."
: i % 3 === 1
? "Scanned from physical document."
: "Generated by system.",
objectKey: `edr-freight/${linkedType.toLowerCase()}/${linkedReference}/${name}`,
};
},
);
export function getDocumentById(id: number | string): DocumentRecord | undefined {
export function getDocumentById(
id: number | string,
): DocumentRecord | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return documents.find((d) => d.id === numericId);
}

View File

@@ -1,486 +0,0 @@
import { useEffect, useMemo } from "react";
import { Link } from "react-router-dom";
import {
ArrowRight,
Building2,
CheckCircle2,
Clock,
DollarSign,
Eye,
Mail,
MapPin,
Package,
Phone,
Plus,
Receipt,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import type { ShipmentStatus } from "@/pages/tracking/shipments.mock";
import type { InvoiceStatus } from "@/pages/billing/invoices.mock";
import type { BookingStatus } from "@/pages/bookings/bookings.mock";
import { customersService } from "@/services/customers.service";
export default function MyPortalPage() {
const me = useMemo(() => getCurrentCustomer(), []);
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
const userId = localStorage.getItem("userId");
useEffect(() => {
customersService.getByUserId(userId || "").then((res: any) => {
}).catch((err) => {
console.error(err)
})
}, [userId]);
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(inv) => inv.status === "Sent" || inv.status === "Overdue",
);
const totalOutstanding = outstandingInvoices
.filter((inv) => inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const totalSpent = myInvoices
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
.reduce((sum, inv) => sum + inv.amount, 0);
const recentBookings = [...myBookings].slice(0, 5);
const recentInvoices = [...myInvoices].slice(0, 4);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "My Portal" }]} />
{/* Welcome banner */}
<div className="overflow-hidden rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/15 text-2xl font-bold backdrop-blur">
{me.company.charAt(0)}
</div>
<div>
<p className="text-sm text-white/80">Welcome back</p>
<h1 className="text-3xl font-bold tracking-tight">
{me.name}
</h1>
<p className="mt-1 flex items-center gap-2 text-sm text-white/80">
<Building2 className="h-4 w-4" />
{me.company}
<span className="text-white/40">·</span>
<span className="rounded-full bg-white/15 px-2 py-0.5 text-xs">
{me.customerType}
</span>
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Link
to="/bookings/new"
className="inline-flex items-center gap-2 rounded-2xl bg-white px-4 py-2 text-sm font-semibold text-[#10B981] transition hover:bg-slate-100"
>
<Plus className="h-4 w-4" />
New Booking
</Link>
<Link
to="/tracking"
className="inline-flex items-center gap-2 rounded-2xl border border-white/40 px-4 py-2 text-sm font-medium text-white transition hover:bg-white/10"
>
<Truck className="h-4 w-4" />
Track Shipment
</Link>
</div>
</div>
</div>
{/* My KPIs */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="My Active Bookings"
value={String(activeBookings.length)}
sub={`${myBookings.length} total`}
icon={<Package className="h-5 w-5" />}
href="/bookings"
/>
<KpiCard
label="In Transit"
value={String(activeShipments.length)}
sub={`${myShipments.length} shipments`}
icon={<Truck className="h-5 w-5" />}
href="/tracking"
/>
<KpiCard
label="Outstanding"
value={formatCurrency(totalOutstanding, "USD")}
sub={`${outstandingInvoices.length} invoices`}
icon={<DollarSign className="h-5 w-5" />}
href="/billing"
tone={outstandingInvoices.some((i) => i.status === "Overdue") ? "danger" : "brand"}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="All-time, paid invoices"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
{/* Active 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">
Active Shipments
</h2>
<p className="text-sm text-slate-500">
Live tracking for your in-flight cargo
</p>
</div>
<Link
to="/tracking"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{activeShipments.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No shipments currently in transit.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2">
{activeShipments.slice(0, 4).map((shipment) => (
<div
key={shipment.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
>
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-900">
{shipment.reference}
</span>
<ShipmentBadge status={shipment.status} />
</div>
<p className="mt-1 text-sm text-slate-700">
{shipment.originStation}
<ArrowRight className="mx-2 inline h-3 w-3 text-slate-400" />
{shipment.destinationStation}
</p>
<div className="mt-3 flex items-center justify-between text-xs text-slate-500">
<span className="flex items-center gap-1">
<MapPin className="h-3 w-3 text-[#10B981]" />
{shipment.currentLocation}
</span>
<span>ETA {shipment.eta}</span>
</div>
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-[#10B981] transition-all"
style={{ width: `${shipment.progress}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
{/* Recent Bookings + Invoices + Profile */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Recent bookings */}
<div className="rounded-3xl bg-white p-6 shadow-sm lg:col-span-2">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-900">
Recent Bookings
</h2>
<p className="text-sm text-slate-500">
Your latest freight requests
</p>
</div>
<Link
to="/bookings"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentBookings.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
You haven't booked any freight yet.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[600px] whitespace-nowrap text-left text-sm">
<thead className="text-xs text-slate-500">
<tr>
<th className="py-2 font-medium">Reference</th>
<th className="py-2 font-medium">Route</th>
<th className="py-2 font-medium">Cargo</th>
<th className="py-2 font-medium">Status</th>
<th className="py-2 text-right font-medium">Action</th>
</tr>
</thead>
<tbody>
{recentBookings.map((booking) => (
<tr
key={booking.id}
className="border-t border-slate-100 transition hover:bg-[#10B981]/5"
>
<td className="py-3 font-medium text-slate-900">
{booking.reference}
</td>
<td className="py-3 text-slate-700">
{booking.originStation} {booking.destinationStation}
</td>
<td className="py-3 text-slate-700">
{booking.cargoType}
</td>
<td className="py-3">
<BookingBadge status={booking.status} />
</td>
<td className="py-3 text-right">
<Link
to={`/bookings/${booking.id}`}
aria-label="View booking"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-slate-500 transition hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
<Eye className="h-4 w-4" />
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Profile card */}
<div className="rounded-3xl bg-white p-6 shadow-sm">
<h2 className="text-lg font-semibold text-slate-900">
My Profile
</h2>
<p className="text-sm text-slate-500">Account information</p>
<div className="mt-4 space-y-3 text-sm">
<ProfileRow
icon={<Building2 className="h-4 w-4" />}
label="Company"
value={me.company}
/>
<ProfileRow
icon={<Mail className="h-4 w-4" />}
label="Email"
value={me.email}
/>
<ProfileRow
icon={<Phone className="h-4 w-4" />}
label="Phone"
value={me.phone}
/>
<ProfileRow
icon={<MapPin className="h-4 w-4" />}
label="Location"
value={`${me.city}, ${me.country}`}
/>
</div>
<Link
to={`/customers/${me.id}`}
className="mt-4 inline-flex w-full items-center justify-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#10B981]/30 hover:bg-[#10B981]/10 hover:text-[#10B981]"
>
View full profile
<ArrowRight className="h-4 w-4" />
</Link>
</div>
</div>
{/* Invoices */}
<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 Invoices
</h2>
<p className="text-sm text-slate-500">
{outstandingInvoices.length} outstanding ·{" "}
{myInvoices.length} total
</p>
</div>
<Link
to="/billing"
className="inline-flex items-center gap-1 text-sm font-medium text-[#10B981] transition hover:underline"
>
View all
<ArrowRight className="h-4 w-4" />
</Link>
</div>
{recentInvoices.length === 0 ? (
<p className="rounded-2xl border border-dashed border-slate-200 p-8 text-center text-sm text-slate-500">
No invoices yet.
</p>
) : (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{recentInvoices.map((invoice) => (
<div
key={invoice.id}
className="rounded-2xl border border-slate-100 p-4 transition hover:border-[#10B981]/20 hover:bg-[#10B981]/5"
>
<div className="flex items-center justify-between">
<Receipt className="h-4 w-4 text-[#10B981]" />
<InvoiceBadge status={invoice.status} />
</div>
<p className="mt-2 text-xs text-slate-500">
{invoice.number}
</p>
<p className="mt-0.5 text-lg font-bold text-slate-900">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
<p className="mt-1 flex items-center gap-1 text-xs text-slate-500">
<Clock className="h-3 w-3" />
Due {invoice.dueDate}
</p>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
}
function KpiCard({
label,
value,
sub,
icon,
href,
tone = "brand",
}: {
label: string;
value: string;
sub: string;
icon: React.ReactNode;
href?: string;
tone?: "brand" | "danger";
}) {
const iconWrap =
tone === "danger"
? "bg-red-100 text-red-600"
: "bg-[#10B981] text-white";
const inner = (
<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>
<p className="mt-1 text-xs text-slate-500">{sub}</p>
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconWrap}`}
>
{icon}
</div>
</div>
);
const className =
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
return href ? (
<Link to={href} className={`block ${className}`}>
{inner}
</Link>
) : (
<div className={className}>{inner}</div>
);
}
function ProfileRow({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-[#10B981]">{icon}</div>
<div className="flex-1">
<p className="text-xs font-medium text-slate-500">{label}</p>
<p className="mt-0.5 text-sm text-slate-900">{value}</p>
</div>
</div>
);
}
function ShipmentBadge({ status }: { status: ShipmentStatus }) {
const styles: Record<ShipmentStatus, string> = {
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Delayed: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function BookingBadge({ status }: { status: BookingStatus }) {
const styles: Record<BookingStatus, string> = {
Pending: "bg-amber-100 text-amber-700",
Confirmed: "bg-sky-100 text-sky-700",
"In Transit": "bg-indigo-100 text-indigo-700",
Delivered: "bg-emerald-100 text-emerald-700",
Cancelled: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}
function InvoiceBadge({ status }: { status: InvoiceStatus }) {
const styles: Record<InvoiceStatus, string> = {
Draft: "bg-slate-100 text-slate-600",
Sent: "bg-sky-100 text-sky-700",
Paid: "bg-emerald-100 text-emerald-700",
Overdue: "bg-red-100 text-red-700",
Cancelled: "bg-amber-100 text-amber-700",
};
return (
<span
className={`inline-flex rounded-full px-2.5 py-0.5 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,257 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import {
Building2,
CheckCircle2,
DollarSign,
Package,
Plus,
Truck,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import {
getCurrentCustomer,
getMyBookings,
getMyInvoices,
getMyShipments,
} from "@/lib/currentCustomer";
import { formatCurrency } from "@/pages/billing/invoices.mock";
import { customersService } from "@/services/customers.service";
import { authService } from "@/services/auth.service";
import NewCustomerPage from "../customers/NewCustomerPage";
import { Button } from "@edr/ui-common";
export default function MyPortalPage() {
const navigate = useNavigate();
// ------------------------------------------------------------
// STATE
// ------------------------------------------------------------
const [loading, setLoading] = useState(true);
const [customer, setCustomer] = useState<any>(null);
// ------------------------------------------------------------
// MOCK DATA
// ------------------------------------------------------------
const myBookings = useMemo(() => getMyBookings(), []);
const myShipments = useMemo(() => getMyShipments(), []);
const myInvoices = useMemo(() => getMyInvoices(), []);
// ------------------------------------------------------------
// LOADING
// ------------------------------------------------------------
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="rounded-2xl bg-white px-6 py-4 shadow-sm">
<p className="text-sm text-slate-600">Loading portal...</p>
</div>
</div>
);
}
// ------------------------------------------------------------
// CUSTOMER MISSING (extra safety)
// ------------------------------------------------------------
if (!customer) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-50">
<div className="text-center space-y-4">
<p className="text-slate-600">No customer profile found</p>
{/* <Link
to="/customers/register"
className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white"
>
<Plus className="h-4 w-4" />
Create Customer Profile
</Link> */}
<NewCustomerPage>
<Button className="inline-flex items-center gap-2 rounded-2xl bg-[#10B981] px-4 py-2 text-white">
<Plus className="h-4 w-4" />
Create Customer Profile
</Button>
</NewCustomerPage>
</div>
</div>
);
}
// ------------------------------------------------------------
// KPI CALCULATIONS
// ------------------------------------------------------------
const activeBookings = myBookings.filter(
(b) => b.status === "Confirmed" || b.status === "In Transit",
);
const activeShipments = myShipments.filter((s) => s.status === "In Transit");
const outstandingInvoices = myInvoices.filter(
(i) => i.status === "Sent" || i.status === "Overdue",
);
const totalOutstanding = outstandingInvoices.reduce(
(sum, i) => (i.currency === "USD" ? sum + i.amount : sum),
0,
);
const totalSpent = myInvoices.reduce(
(sum, i) =>
i.status === "Paid" && i.currency === "USD" ? sum + i.amount : sum,
0,
);
// ------------------------------------------------------------
// RENDER
// ------------------------------------------------------------
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "My Portal" }]} />
{/* HERO */}
<div className="rounded-3xl bg-gradient-to-r from-[#059669] to-[#10B981] p-6 text-white">
<div className="flex justify-between flex-col md:flex-row gap-6">
<div className="flex items-center gap-4">
<div className="h-14 w-14 flex items-center justify-center rounded-2xl bg-white/20 text-xl font-bold">
{customer.companyName?.charAt(0)}
</div>
<div>
<h1 className="text-2xl font-bold">
{customer.firstName} {customer.lastName}
</h1>
<p className="text-sm opacity-80 flex items-center gap-2">
<Building2 className="h-4 w-4" />
{customer.companyName}
</p>
</div>
</div>
<div className="flex gap-2">
<Link
to="/bookings/new"
className="bg-white text-[#10B981] px-4 py-2 rounded-xl font-semibold flex items-center gap-2"
>
<Plus className="h-4 w-4" />
New Booking
</Link>
<Link
to="/tracking"
className="border border-white px-4 py-2 rounded-xl flex items-center gap-2"
>
<Truck className="h-4 w-4" />
Track
</Link>
</div>
</div>
</div>
{/* KPI */}
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<KpiCard
label="Active Bookings"
value={String(activeBookings.length)}
sub={`${myBookings.length} total`}
icon={<Package className="h-5 w-5" />}
/>
<KpiCard
label="In Transit"
value={String(activeShipments.length)}
sub={`${myShipments.length} shipments`}
icon={<Truck className="h-5 w-5" />}
/>
<KpiCard
label="Outstanding"
value={formatCurrency(totalOutstanding, "USD")}
sub={`${outstandingInvoices.length} invoices`}
icon={<DollarSign className="h-5 w-5" />}
tone={
outstandingInvoices.some((i) => i.status === "Overdue")
? "danger"
: "brand"
}
/>
<KpiCard
label="Total Spent"
value={formatCurrency(totalSpent, "USD")}
sub="Paid invoices"
icon={<CheckCircle2 className="h-5 w-5" />}
/>
</div>
</div>
</div>
);
}
// ------------------------------------------------------------
// KPI CARD
// ------------------------------------------------------------
function KpiCard({
label,
value,
sub,
icon,
href,
tone = "brand",
}: {
label: string;
value: string;
sub: string;
icon: React.ReactNode;
href?: string;
tone?: "brand" | "danger";
}) {
const iconClassName =
tone === "danger" ? "bg-red-100 text-red-600" : "bg-[#10B981] text-white";
const content = (
<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>
<p className="mt-1 text-xs text-slate-500">{sub}</p>
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-2xl ${iconClassName}`}
>
{icon}
</div>
</div>
);
const className =
"rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#10B981]/20";
if (href) {
return (
<Link to={href} className={`block ${className}`}>
{content}
</Link>
);
}
return <div className={className}>{content}</div>;
}

View File

@@ -1,61 +0,0 @@
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 DeleteTrainDialogProps {
trainCode: string;
onConfirm?: () => void;
children: ReactNode;
}
export default function DeleteTrainDialog({
trainCode,
onConfirm,
children,
}: DeleteTrainDialogProps) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-md rounded-3xl">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
Retire train?
</DialogTitle>
<DialogDescription>
This will permanently retire train{" "}
<span className="font-semibold text-slate-900">{trainCode}</span>{" "}
from the fleet. This action cannot be undone.
</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"
>
Retire
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,240 +0,0 @@
import type { ReactNode } from "react";
import {
Building2,
Calendar,
Factory,
Gauge,
MapPin,
Train as TrainIcon,
Weight,
} 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 { TrainStatus, TrainType } from "./trains.mock";
export interface TrainFormData {
code?: string;
name?: string;
type?: TrainType;
status?: TrainStatus;
capacityTons?: number;
depot?: string;
manufacturer?: string;
mileageKm?: number;
lastMaintenance?: string;
nextMaintenance?: string;
currentAssignment?: string;
yearBuilt?: number;
}
export interface NewTrainPageProps {
mode?: "create" | "edit";
train?: TrainFormData;
children?: ReactNode;
}
const selectClass =
"flex h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition hover:border-slate-300 focus:border-[#10B981]/50 focus:ring-2 focus:ring-[#10B981]/20";
export default function NewTrainPage({
mode = "create",
train,
children,
}: NewTrainPageProps = {}) {
const isEdit = mode === "edit";
const title = isEdit ? "Edit Train" : "New Train";
const description = isEdit
? "Update train fleet information."
: "Add a new train to the fleet roster.";
const submitLabel = isEdit ? "Save Changes" : "Add Train";
return (
<Dialog>
<DialogTrigger asChild>
{children ?? <Button>{isEdit ? "Edit" : "New Train"}</Button>}
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl rounded-3xl">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="grid gap-5 py-4 md:grid-cols-2">
{/* Code */}
<div className="space-y-2">
<Label>Train Code *</Label>
<div className="relative">
<TrainIcon className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.code ?? ""}
placeholder="e.g. LOC-001"
className="pl-10"
/>
</div>
</div>
{/* Name */}
<div className="space-y-2">
<Label>Name</Label>
<Input
defaultValue={train?.name ?? ""}
placeholder="e.g. Awash Express"
/>
</div>
{/* Type */}
<div className="space-y-2">
<Label>Type *</Label>
<select
defaultValue={train?.type ?? "Locomotive"}
className={selectClass}
>
<option>Locomotive</option>
<option>Freight Wagon</option>
<option>Tanker Wagon</option>
<option>Container Wagon</option>
<option>Reefer Wagon</option>
</select>
</div>
{/* Status */}
<div className="space-y-2">
<Label>Status</Label>
<select
defaultValue={train?.status ?? "Operational"}
className={selectClass}
>
<option>Operational</option>
<option>In Maintenance</option>
<option>Idle</option>
<option>Out of Service</option>
</select>
</div>
{/* Capacity */}
<div className="space-y-2">
<Label>Capacity (Tons)</Label>
<div className="relative">
<Weight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.capacityTons ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Depot */}
<div className="space-y-2">
<Label>Depot</Label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.depot ?? ""}
placeholder="e.g. Addis Ababa"
className="pl-10"
/>
</div>
</div>
{/* Manufacturer */}
<div className="space-y-2">
<Label>Manufacturer</Label>
<div className="relative">
<Factory className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.manufacturer ?? ""}
placeholder="e.g. CRRC Zhuzhou"
className="pl-10"
/>
</div>
</div>
{/* Year Built */}
<div className="space-y-2">
<Label>Year Built</Label>
<Input
type="number"
min={1950}
max={2030}
defaultValue={train?.yearBuilt ?? 2020}
/>
</div>
{/* Mileage */}
<div className="space-y-2">
<Label>Mileage (km)</Label>
<div className="relative">
<Gauge className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="number"
min={0}
defaultValue={train?.mileageKm ?? 0}
className="pl-10"
/>
</div>
</div>
{/* Current Assignment */}
<div className="space-y-2">
<Label>Current Assignment</Label>
<div className="relative">
<Building2 className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
defaultValue={train?.currentAssignment ?? ""}
placeholder="e.g. BK-026003 or —"
className="pl-10"
/>
</div>
</div>
{/* Last Maintenance */}
<div className="space-y-2">
<Label>Last Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.lastMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
{/* Next Maintenance */}
<div className="space-y-2">
<Label>Next Maintenance</Label>
<div className="relative">
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
<Input
type="date"
defaultValue={train?.nextMaintenance ?? ""}
className="pl-10"
/>
</div>
</div>
</div>
<div className="flex justify-end gap-3">
<Button variant="outline">Cancel</Button>
<Button className="bg-[#10B981] text-white hover:bg-[#10B981]/90">
{submitLabel}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,372 +0,0 @@
import { useMemo, useState } from "react";
import {
Eye,
Filter,
Gauge,
MoreHorizontal,
Pencil,
Plus,
Search,
Train as TrainIcon,
Trash2,
Wrench,
} from "lucide-react";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewTrainPage from "./NewTrainPage";
import DeleteTrainDialog from "./DeleteTrainDialog";
import { trains, type TrainStatus } from "./trains.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
} from "@edr/ui-common";
type FilterValue = "All" | TrainStatus;
const FILTERS: FilterValue[] = [
"All",
"Operational",
"In Maintenance",
"Idle",
"Out of Service",
];
export default function TrainsPage() {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [filter, setFilter] = useState<FilterValue>("All");
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return trains.filter((t) => {
if (filter !== "All" && t.status !== filter) return false;
if (!q) return true;
return (
t.code.toLowerCase().includes(q) ||
t.name.toLowerCase().includes(q) ||
t.depot.toLowerCase().includes(q) ||
t.type.toLowerCase().includes(q)
);
});
}, [filter, query]);
const total = filtered.length;
const pageCount = Math.ceil(total / pagination.pageSize);
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const operationalCount = trains.filter(
(t) => t.status === "Operational",
).length;
const maintenanceCount = trains.filter(
(t) => t.status === "In Maintenance",
).length;
const columns: ColumnDef<(typeof trains)[number]>[] = [
{
id: "train",
header: "Train",
cell: ({ row }) => {
const t = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<TrainIcon />
</div>
<div>
<p className="font-medium text-slate-900">{t.code}</p>
<p className="text-sm text-slate-500">{t.name}</p>
</div>
</div>
);
},
},
{
accessorKey: "type",
header: "Type",
},
{
accessorKey: "capacityTons",
header: "Capacity",
cell: ({ row }) => <span>{row.original.capacityTons}t</span>,
},
{
accessorKey: "depot",
header: "Depot",
},
{
accessorKey: "mileageKm",
header: "Mileage",
cell: ({ row }) => (
<span>{row.original.mileageKm.toLocaleString()} km</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const train = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem>
<Eye />
View
</DropdownMenuItem>
<NewTrainPage
mode="edit"
train={{
code: train.code,
name: train.name,
type: train.type,
status: train.status,
capacityTons: train.capacityTons,
depot: train.depot,
manufacturer: train.manufacturer,
mileageKm: train.mileageKm,
lastMaintenance: train.lastMaintenance,
nextMaintenance: train.nextMaintenance,
currentAssignment: train.currentAssignment,
yearBuilt: train.yearBuilt,
}}
>
<DropdownMenuItem onSelect={(e: Event) => e.preventDefault()}>
<Pencil />
Edit
</DropdownMenuItem>
</NewTrainPage>
<DropdownMenuSeparator />
<DeleteTrainDialog trainCode={train.code}>
<DropdownMenuItem
onSelect={(e: Event) => e.preventDefault()}
variant="destructive"
>
<Trash2 />
Retire
</DropdownMenuItem>
</DeleteTrainDialog>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Trains" }]} />
<Card className="p-6 flex-row justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Trains
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Fleet roster, capacity, and maintenance status.
</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-2 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);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search trains..."
className="pl-8!"
/>
</div>
<NewTrainPage>
<Button>
<Plus />
New Train
</Button>
</NewTrainPage>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Total Trains</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{trains.length}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<TrainIcon />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">Operational</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{operationalCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Gauge />
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">In Maintenance</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">
{maintenanceCount}
</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Wrench />
</div>
</CardContent>
</Card>
</div>
<Card className="p-2">
<div className="flex flex-wrap gap-1">
{FILTERS.map((f) => {
const isActive = f === filter;
const count =
f === "All"
? trains.length
: trains.filter((t) => t.status === f).length;
return (
<button
key={f}
type="button"
onClick={() => {
setFilter(f);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
className={
isActive
? "inline-flex items-center gap-2 rounded-2xl bg-primary px-4 py-2 text-sm font-medium text-primary-foreground"
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-primary/10 hover:text-primary"
}
>
{f}
<span
className={
isActive
? "rounded-full bg-white/20 px-2 py-0.5 text-xs"
: "rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600"
}
>
{count}
</span>
</button>
);
})}
</div>
</Card>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>Fleet Roster</CardTitle>
<CardDescription>
All locomotives and wagons in service.
</CardDescription>
</div>
<Button variant="secondary" size="sm">
<Filter />
Filter
</Button>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={() => { }}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatusBadge({ status }: { status: TrainStatus }) {
const styles: Record<TrainStatus, string> = {
Operational: "bg-emerald-100 text-emerald-700",
"In Maintenance": "bg-amber-100 text-amber-700",
Idle: "bg-slate-100 text-slate-600",
"Out of Service": "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -1,263 +0,0 @@
export type TrainType =
| "Locomotive"
| "Freight Wagon"
| "Tanker Wagon"
| "Container Wagon"
| "Reefer Wagon";
export type TrainStatus =
| "Operational"
| "In Maintenance"
| "Idle"
| "Out of Service";
export interface Train {
id: number;
code: string;
name: string;
type: TrainType;
status: TrainStatus;
capacityTons: number;
depot: string;
manufacturer: string;
mileageKm: number;
lastMaintenance: string;
nextMaintenance: string;
currentAssignment: string;
yearBuilt: number;
}
const seedTrains: Array<Omit<Train, "id" | "code">> = [
{
name: "Awash Express",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 184320,
lastMaintenance: "2026-04-12",
nextMaintenance: "2026-07-12",
currentAssignment: "BK-026001",
yearBuilt: 2018,
},
{
name: "Rift Valley Hauler",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Adama",
manufacturer: "CRRC Zhuzhou",
mileageKm: 156780,
lastMaintenance: "2026-03-28",
nextMaintenance: "2026-06-28",
currentAssignment: "BK-026004",
yearBuilt: 2019,
},
{
name: "Djibouti Freighter",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Dire Dawa",
manufacturer: "CRRC Yangtze",
mileageKm: 92110,
lastMaintenance: "2026-04-02",
nextMaintenance: "2026-08-02",
currentAssignment: "BK-026003",
yearBuilt: 2020,
},
{
name: "Highlander 1",
type: "Freight Wagon",
status: "In Maintenance",
capacityTons: 80,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 211450,
lastMaintenance: "2026-05-10",
nextMaintenance: "2026-05-20",
currentAssignment: "—",
yearBuilt: 2017,
},
{
name: "Highlander 2",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 198020,
lastMaintenance: "2026-04-18",
nextMaintenance: "2026-07-18",
currentAssignment: "BK-026007",
yearBuilt: 2017,
},
{
name: "Sheba Tanker",
type: "Tanker Wagon",
status: "Operational",
capacityTons: 70,
depot: "Awash",
manufacturer: "CRRC Zhuzhou",
mileageKm: 132540,
lastMaintenance: "2026-04-05",
nextMaintenance: "2026-07-05",
currentAssignment: "BK-026010",
yearBuilt: 2019,
},
{
name: "Lalibela Cooler",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 67890,
lastMaintenance: "2026-03-22",
nextMaintenance: "2026-06-22",
currentAssignment: "—",
yearBuilt: 2021,
},
{
name: "Awash Express II",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Mieso",
manufacturer: "CRRC Zhuzhou",
mileageKm: 145600,
lastMaintenance: "2026-04-22",
nextMaintenance: "2026-07-22",
currentAssignment: "BK-026013",
yearBuilt: 2019,
},
{
name: "Coffee Belt Wagon",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Addis Ababa",
manufacturer: "CRRC Yangtze",
mileageKm: 88240,
lastMaintenance: "2026-04-09",
nextMaintenance: "2026-08-09",
currentAssignment: "BK-026016",
yearBuilt: 2020,
},
{
name: "Red Sea Hauler",
type: "Locomotive",
status: "Out of Service",
capacityTons: 240,
depot: "Djibouti City",
manufacturer: "CRRC Zhuzhou",
mileageKm: 264100,
lastMaintenance: "2026-02-14",
nextMaintenance: "2026-08-14",
currentAssignment: "—",
yearBuilt: 2015,
},
{
name: "Ali Sabieh Express",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Ali Sabieh",
manufacturer: "CRRC Yangtze",
mileageKm: 102330,
lastMaintenance: "2026-04-15",
nextMaintenance: "2026-07-15",
currentAssignment: "BK-026019",
yearBuilt: 2020,
},
{
name: "Holhol Tanker",
type: "Tanker Wagon",
status: "In Maintenance",
capacityTons: 70,
depot: "Holhol",
manufacturer: "CRRC Zhuzhou",
mileageKm: 178600,
lastMaintenance: "2026-05-08",
nextMaintenance: "2026-05-22",
currentAssignment: "—",
yearBuilt: 2018,
},
{
name: "Aysha Carrier",
type: "Container Wagon",
status: "Operational",
capacityTons: 60,
depot: "Aysha",
manufacturer: "CRRC Yangtze",
mileageKm: 75940,
lastMaintenance: "2026-04-19",
nextMaintenance: "2026-08-19",
currentAssignment: "BK-026022",
yearBuilt: 2021,
},
{
name: "Mojo Reefer",
type: "Reefer Wagon",
status: "Idle",
capacityTons: 55,
depot: "Mojo",
manufacturer: "CRRC Yangtze",
mileageKm: 49870,
lastMaintenance: "2026-04-01",
nextMaintenance: "2026-07-01",
currentAssignment: "—",
yearBuilt: 2022,
},
{
name: "Simien Locomotive",
type: "Locomotive",
status: "Operational",
capacityTons: 240,
depot: "Addis Ababa",
manufacturer: "CRRC Zhuzhou",
mileageKm: 121340,
lastMaintenance: "2026-04-25",
nextMaintenance: "2026-07-25",
currentAssignment: "BK-026008",
yearBuilt: 2020,
},
{
name: "Gibe Freight",
type: "Freight Wagon",
status: "Operational",
capacityTons: 80,
depot: "Adama",
manufacturer: "CRRC Yangtze",
mileageKm: 168200,
lastMaintenance: "2026-04-11",
nextMaintenance: "2026-07-11",
currentAssignment: "BK-026011",
yearBuilt: 2018,
},
];
export const trains: Train[] = seedTrains.map((entry, i) => {
const id = i + 1;
const prefix =
entry.type === "Locomotive"
? "LOC"
: entry.type === "Tanker Wagon"
? "TNK"
: entry.type === "Reefer Wagon"
? "RFR"
: entry.type === "Container Wagon"
? "CNT"
: "WGN";
return {
id,
code: `${prefix}-${String(id).padStart(3, "0")}`,
...entry,
};
});
export function getTrainById(id: number | string): Train | undefined {
const numericId = typeof id === "string" ? Number(id) : id;
return trains.find((t) => t.id === numericId);
}

View File

@@ -1,31 +1,23 @@
import type { Freight, PaginatedResponse } from "@edr/types";
import { client as api } from "@/utils/api";
import { client } from "../utils/api";
export type CreateBookingPayload = Freight.CreateBookingDto;
export const bookingsService = {
list: async (): Promise<PaginatedResponse<Freight.IBooking>> => {
const { data } = await api.get("/bookings");
const { data } = await client.get("/bookings");
return data.data;
},
get: async (id: string): Promise<Freight.IBooking> => {
const { data } = await api.get(`/bookings/${id}`);
const { data } = await client.get(`/bookings/${id}`);
return data.data;
},
create: async (payload: CreateBookingPayload): Promise<Freight.IBooking> => {
const fd = new FormData();
for (const [key, value] of Object.entries(payload)) {
if (value === undefined || value === null) continue;
if (Array.isArray(value) || typeof value === "object") {
fd.append(key, JSON.stringify(value));
} else {
fd.append(key, String(value));
}
}
const { data } = await api.post("/api/bookings", fd);
const { data } = await client.post("/api/bookings", payload);
return data.data;
},
remove: async (id: string): Promise<void> => {
await api.delete(`/bookings/${id}`);
await client.delete(`/bookings/${id}`);
},
};

3
pnpm-lock.yaml generated
View File

@@ -53,6 +53,9 @@ importers:
'@nestjs/core':
specifier: ^11.0.0
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.23)(@nestjs/platform-express@11.1.23)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/mapped-types':
specifier: ^2.1.1
version: 2.1.1(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/microservices':
specifier: ^11.0.0
version: 11.1.23(@nestjs/common@11.1.23(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.23)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)