mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
add ui for major components
This commit is contained in:
@@ -1 +1,42 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgb(203 213 225 / 0.6) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgb(203 213 225 / 0.7);
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgb(51 87 141 / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark * {
|
||||||
|
scrollbar-color: rgb(71 85 105 / 0.6) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgb(71 85 105 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark *::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgb(51 87 141 / 0.7);
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@
|
|||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"@tria-plc/iamui-common": "1.1.1",
|
"@tria-plc/iamui-common": "1.1.1",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-router-dom": "^6.27.0",
|
"react-router-dom": "^6.27.0",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
"zustand": "^5.0.0"
|
"zustand": "^5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -6,10 +6,20 @@ import {
|
|||||||
Navigate,
|
Navigate,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Users,
|
||||||
|
CalendarCheck,
|
||||||
|
Package,
|
||||||
|
MapPin,
|
||||||
|
Train,
|
||||||
|
Receipt,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingsPage from "./pages/bookings/BookingsPage";
|
import BookingsPage from "./pages/bookings/BookingsPage";
|
||||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||||
import CreateBookingPage from "./pages/bookings/CreateBookingPage";
|
import NewBookingPage from "./pages/bookings/NewBookingPage";
|
||||||
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
import ConsignmentsPage from "./pages/consignments/ConsignmentsPage";
|
||||||
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
|
import ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
@@ -17,16 +27,20 @@ import BillingPage from "./pages/billing/BillingPage";
|
|||||||
import TrainsPage from "./pages/trains/TrainsPage";
|
import TrainsPage from "./pages/trains/TrainsPage";
|
||||||
import DashboardPage from "./pages/dashboard/DashboardPage";
|
import DashboardPage from "./pages/dashboard/DashboardPage";
|
||||||
import { IamLoginPage } from "@tria-plc/iamui-common";
|
import { IamLoginPage } from "@tria-plc/iamui-common";
|
||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
|
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||||
|
import NewCustomerPage from "./pages/customers/NewCustomerPage";
|
||||||
|
import DocumentsPage from "./pages/documents/DocumentsPage";
|
||||||
|
|
||||||
const sidebarItems: SidebarItem[] = [
|
const sidebarItems: SidebarItem[] = [
|
||||||
{ label: "Dashboard", href: "/" },
|
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
|
||||||
{ label: "Customers", href: "/Customers" },
|
{ label: "Customers", href: "/customers", icon: <Users /> },
|
||||||
{ label: "Bookings", href: "/bookings" },
|
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
|
||||||
{ label: "Consignments", href: "/consignments" },
|
{ label: "Consignments", href: "/consignments", icon: <Package /> },
|
||||||
{ label: "Tracking", href: "/tracking" },
|
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
|
||||||
{ label: "Trains", href: "/trains" },
|
{ label: "Trains", href: "/trains", icon: <Train /> },
|
||||||
{ label: "Billing", href: "/billing" },
|
{ label: "Billing", href: "/billing", icon: <Receipt /> },
|
||||||
|
{ label: "Documents", href: "/documents", icon: <FileText /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
@@ -48,18 +62,22 @@ const App = () => {
|
|||||||
sidebarItems={sidebarItems}
|
sidebarItems={sidebarItems}
|
||||||
activeHref={location.pathname}
|
activeHref={location.pathname}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
|
enableThemeToggle
|
||||||
>
|
>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<DashboardPage />} />
|
<Route path="/" element={<DashboardPage />} />
|
||||||
<Route path="/bookings" element={<BookingsPage />} />
|
<Route path="/bookings" element={<BookingsPage />} />
|
||||||
<Route path="/customers" element={<CustomersPage />} />
|
<Route path="/customers" element={<CustomersPage />} />
|
||||||
<Route path="/bookings/new" element={<CreateBookingPage />} />
|
<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="/bookings/:id" element={<BookingDetailPage />} />
|
||||||
<Route path="/consignments" element={<ConsignmentsPage />} />
|
<Route path="/consignments" element={<ConsignmentsPage />} />
|
||||||
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
|
||||||
<Route path="/tracking" element={<TrackingPage />} />
|
<Route path="/tracking" element={<TrackingPage />} />
|
||||||
<Route path="/trains" element={<TrainsPage />} />
|
<Route path="/trains" element={<TrainsPage />} />
|
||||||
<Route path="/billing" element={<BillingPage />} />
|
<Route path="/billing" element={<BillingPage />} />
|
||||||
|
<Route path="/documents" element={<DocumentsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
56
apps/edr-freight-web/portal/src/components/Breadcrumbs.tsx
Normal file
56
apps/edr-freight-web/portal/src/components/Breadcrumbs.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { ChevronRight, Home } from "lucide-react";
|
||||||
|
|
||||||
|
export interface BreadcrumbItem {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BreadcrumbsProps {
|
||||||
|
items: BreadcrumbItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Breadcrumbs({ items }: BreadcrumbsProps) {
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb"
|
||||||
|
className="flex items-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
aria-label="Home"
|
||||||
|
className="flex items-center transition hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
{/* <Home className="h-4 w-4" /> */}
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const isLast = i === items.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={`${item.label}-${i}`}>
|
||||||
|
<ChevronRight className="mx-2 h-4 w-4 text-slate-300" />
|
||||||
|
|
||||||
|
{item.href && !isLast ? (
|
||||||
|
<Link
|
||||||
|
to={item.href}
|
||||||
|
className="transition hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-current={isLast ? "page" : undefined}
|
||||||
|
className="font-medium text-slate-900"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/edr-freight-web/portal/src/components/ui/button.tsx
Normal file
58
apps/edr-freight-web/portal/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import { Slot } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
size = "default",
|
||||||
|
asChild = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot.Root : "button";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
141
apps/edr-freight-web/portal/src/components/ui/dialog.tsx
Normal file
141
apps/edr-freight-web/portal/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Dialog as DialogPrimitive } from "radix-ui";
|
||||||
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Dialog({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
showCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal data-slot="dialog-portal">
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn("text-lg leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
};
|
||||||
22
apps/edr-freight-web/portal/src/components/ui/input.tsx
Normal file
22
apps/edr-freight-web/portal/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full min-w-0 rounded-md border border-slate-200 bg-white px-3 py-1 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"hover:border-slate-300",
|
||||||
|
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
|
||||||
|
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
22
apps/edr-freight-web/portal/src/components/ui/label.tsx
Normal file
22
apps/edr-freight-web/portal/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Label as LabelPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
21
apps/edr-freight-web/portal/src/components/ui/textarea.tsx
Normal file
21
apps/edr-freight-web/portal/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"field-sizing-content flex min-h-16 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 shadow-xs outline-none transition placeholder:text-slate-400 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
"hover:border-slate-300",
|
||||||
|
"focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20",
|
||||||
|
"aria-invalid:border-red-500 aria-invalid:ring-2 aria-invalid:ring-red-500/20",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea };
|
||||||
6
apps/edr-freight-web/portal/src/lib/utils.ts
Normal file
6
apps/edr-freight-web/portal/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -1,12 +1,473 @@
|
|||||||
const BillingPage = () => (
|
import { useMemo, useState } from "react";
|
||||||
<div className="flex flex-col gap-4">
|
import {
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Billing</h1>
|
AlertCircle,
|
||||||
<p className="text-sm text-gray-600">
|
ChevronLeft,
|
||||||
Invoice list and payment status will live here. Wire up
|
ChevronRight,
|
||||||
@tanstack/react-query to <code>/billing/invoices</code> when the feature
|
Clock,
|
||||||
is built out.
|
DollarSign,
|
||||||
</p>
|
Download,
|
||||||
</div>
|
Filter,
|
||||||
);
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Receipt,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default BillingPage;
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewInvoicePage from "./NewInvoicePage";
|
||||||
|
import DeleteInvoiceDialog from "./DeleteInvoiceDialog";
|
||||||
|
import {
|
||||||
|
formatCurrency,
|
||||||
|
invoices,
|
||||||
|
type InvoiceStatus,
|
||||||
|
} from "./invoices.mock";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
|
|
||||||
|
type FilterValue = "All" | InvoiceStatus;
|
||||||
|
|
||||||
|
const FILTERS: FilterValue[] = [
|
||||||
|
"All",
|
||||||
|
"Draft",
|
||||||
|
"Sent",
|
||||||
|
"Paid",
|
||||||
|
"Overdue",
|
||||||
|
"Cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BillingPage() {
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filter, setFilter] = useState<FilterValue>("All");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return invoices.filter((inv) => {
|
||||||
|
if (filter !== "All" && inv.status !== filter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
inv.number.toLowerCase().includes(q) ||
|
||||||
|
inv.customer.toLowerCase().includes(q) ||
|
||||||
|
inv.bookingReference.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [filter, query]);
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, total);
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[filtered, start, end],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalRevenue = invoices
|
||||||
|
.filter((inv) => inv.status === "Paid" && inv.currency === "USD")
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const outstanding = invoices
|
||||||
|
.filter(
|
||||||
|
(inv) =>
|
||||||
|
(inv.status === "Sent" || inv.status === "Overdue") &&
|
||||||
|
inv.currency === "USD",
|
||||||
|
)
|
||||||
|
.reduce((sum, inv) => sum + inv.amount, 0);
|
||||||
|
const overdueCount = invoices.filter((inv) => inv.status === "Overdue").length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Billing" }]} />
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
Billing
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Manage invoices, payments, and financial 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-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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Search invoices..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewInvoicePage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Invoice
|
||||||
|
</button>
|
||||||
|
</NewInvoicePage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Revenue (USD)"
|
||||||
|
value={formatCurrency(totalRevenue, "USD")}
|
||||||
|
icon={<DollarSign className="h-5 w-5" />}
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Outstanding (USD)"
|
||||||
|
value={formatCurrency(outstanding, "USD")}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
tone="brand"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Overdue Invoices"
|
||||||
|
value={String(overdueCount)}
|
||||||
|
icon={<AlertCircle className="h-5 w-5" />}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="rounded-3xl bg-white p-2 shadow-sm">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{FILTERS.map((f) => {
|
||||||
|
const isActive = f === filter;
|
||||||
|
const count =
|
||||||
|
f === "All"
|
||||||
|
? invoices.length
|
||||||
|
: invoices.filter((inv) => inv.status === f).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(f);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
||||||
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Invoices 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">
|
||||||
|
Invoices
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Issued invoices and their payment status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[900px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Invoice</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Customer</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Booking</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Amount</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Due Date</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{paginated.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-6 py-12 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No invoices match your filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginated.map((invoice) => (
|
||||||
|
<tr
|
||||||
|
key={invoice.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<Receipt className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{invoice.number}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Issued {invoice.issueDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{invoice.customer}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{invoice.bookingReference}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm font-medium text-slate-900">
|
||||||
|
{formatCurrency(invoice.amount, invoice.currency)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{invoice.dueDate}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={invoice.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Download invoice"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<NewInvoicePage
|
||||||
|
mode="edit"
|
||||||
|
invoice={{
|
||||||
|
number: invoice.number,
|
||||||
|
customerId: invoice.customerId,
|
||||||
|
bookingReference: invoice.bookingReference,
|
||||||
|
amount: invoice.amount,
|
||||||
|
currency: invoice.currency,
|
||||||
|
status: invoice.status,
|
||||||
|
issueDate: invoice.issueDate,
|
||||||
|
dueDate: invoice.dueDate,
|
||||||
|
notes: invoice.notes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewInvoicePage>
|
||||||
|
|
||||||
|
<DeleteInvoiceDialog invoiceNumber={invoice.number}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteInvoiceDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label htmlFor="invoice-page-size" className="font-medium text-slate-700">
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="invoice-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tone?: "brand" | "danger";
|
||||||
|
}) {
|
||||||
|
const iconWrap =
|
||||||
|
tone === "danger"
|
||||||
|
? "bg-red-100 text-red-600"
|
||||||
|
: "bg-[#33578D]/10 text-[#33578D]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ 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-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 DeleteInvoiceDialogProps {
|
||||||
|
invoiceNumber: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteInvoiceDialog({
|
||||||
|
invoiceNumber,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeleteInvoiceDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
Void invoice?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This will void invoice{" "}
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{invoiceNumber}
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Void Invoice
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
202
apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx
Normal file
202
apps/edr-freight-web/portal/src/pages/billing/NewInvoicePage.tsx
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Calendar, DollarSign, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { customers } from "../customers/customers.mock";
|
||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
import type { Currency, InvoiceStatus } from "./invoices.mock";
|
||||||
|
|
||||||
|
export interface InvoiceFormData {
|
||||||
|
number?: string;
|
||||||
|
customerId?: number;
|
||||||
|
bookingReference?: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: Currency;
|
||||||
|
status?: InvoiceStatus;
|
||||||
|
issueDate?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewInvoicePageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
invoice?: InvoiceFormData;
|
||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
||||||
|
|
||||||
|
export default function NewInvoicePage({
|
||||||
|
mode = "create",
|
||||||
|
invoice,
|
||||||
|
children,
|
||||||
|
}: NewInvoicePageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const title = isEdit ? "Edit Invoice" : "New Invoice";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update invoice details."
|
||||||
|
: "Create a new invoice for a customer booking.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Create Invoice";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "New Invoice"}</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">
|
||||||
|
{/* Invoice Number */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Invoice Number *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={invoice?.number ?? ""}
|
||||||
|
placeholder="e.g. INV-2026-0001"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={invoice?.status ?? "Draft"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Draft</option>
|
||||||
|
<option>Sent</option>
|
||||||
|
<option>Paid</option>
|
||||||
|
<option>Overdue</option>
|
||||||
|
<option>Cancelled</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Customer */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Customer *</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={invoice?.customerId ?? ""}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select customer
|
||||||
|
</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.company}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Booking Reference</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={invoice?.bookingReference ?? ""}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="">No linked booking</option>
|
||||||
|
{bookings.map((b) => (
|
||||||
|
<option key={b.id} value={b.reference}>
|
||||||
|
{b.reference} — {b.customer}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Amount */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Amount *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step="0.01"
|
||||||
|
defaultValue={invoice?.amount ?? 0}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Currency */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Currency</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={invoice?.currency ?? "USD"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>USD</option>
|
||||||
|
<option>ETB</option>
|
||||||
|
<option>DJF</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Issue Date *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
defaultValue={invoice?.issueDate ?? ""}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Due Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Due Date *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
defaultValue={invoice?.dueDate ?? ""}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
defaultValue={invoice?.notes ?? ""}
|
||||||
|
placeholder="Payment terms, references, etc."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { customers } from "../customers/customers.mock";
|
||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
|
||||||
|
export type InvoiceStatus =
|
||||||
|
| "Draft"
|
||||||
|
| "Sent"
|
||||||
|
| "Paid"
|
||||||
|
| "Overdue"
|
||||||
|
| "Cancelled";
|
||||||
|
|
||||||
|
export type Currency = "USD" | "ETB" | "DJF";
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
customerId: number;
|
||||||
|
customer: string;
|
||||||
|
bookingReference: string;
|
||||||
|
amount: number;
|
||||||
|
currency: Currency;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
issueDate: string;
|
||||||
|
dueDate: string;
|
||||||
|
paidDate: string | null;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statuses: InvoiceStatus[] = [
|
||||||
|
"Draft",
|
||||||
|
"Sent",
|
||||||
|
"Paid",
|
||||||
|
"Overdue",
|
||||||
|
"Cancelled",
|
||||||
|
];
|
||||||
|
const currencies: Currency[] = ["USD", "ETB", "DJF"];
|
||||||
|
|
||||||
|
export const invoices: Invoice[] = Array.from({ length: 24 }, (_, i) => {
|
||||||
|
const customer = customers[i % customers.length] as (typeof customers)[number];
|
||||||
|
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
||||||
|
const id = i + 1;
|
||||||
|
const issue = new Date(2026, 3, 1 + (i % 28));
|
||||||
|
const due = new Date(issue);
|
||||||
|
due.setDate(due.getDate() + 30);
|
||||||
|
const status = statuses[i % statuses.length] as InvoiceStatus;
|
||||||
|
const currency = currencies[i % currencies.length] as Currency;
|
||||||
|
const baseAmount = 5000 + (i * 1234) % 25000;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
number: `INV-2026-${String(id).padStart(4, "0")}`,
|
||||||
|
customerId: customer.id,
|
||||||
|
customer: customer.company,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
amount: Math.round(baseAmount * 100) / 100,
|
||||||
|
currency,
|
||||||
|
status,
|
||||||
|
issueDate: issue.toISOString().slice(0, 10),
|
||||||
|
dueDate: due.toISOString().slice(0, 10),
|
||||||
|
paidDate:
|
||||||
|
status === "Paid"
|
||||||
|
? new Date(due.getTime() - 86400000 * (i % 7))
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)
|
||||||
|
: null,
|
||||||
|
notes:
|
||||||
|
i % 3 === 0
|
||||||
|
? "Net 30 payment terms."
|
||||||
|
: i % 3 === 1
|
||||||
|
? "Bank transfer preferred."
|
||||||
|
: "Payment due upon receipt.",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getInvoiceById(id: number | string): Invoice | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return invoices.find((inv) => inv.id === numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrency(amount: number, currency: Currency): string {
|
||||||
|
const symbols: Record<Currency, string> = {
|
||||||
|
USD: "$",
|
||||||
|
ETB: "Br",
|
||||||
|
DJF: "DJF",
|
||||||
|
};
|
||||||
|
return `${symbols[currency]} ${amount.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
}
|
||||||
@@ -1,34 +1,326 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRight,
|
||||||
|
Calendar,
|
||||||
|
Flag,
|
||||||
|
MapPin,
|
||||||
|
Package,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
Train,
|
||||||
|
User,
|
||||||
|
Weight,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import { useBooking } from "../../hooks/useBookings";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewBookingPage from "./NewBookingPage";
|
||||||
|
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||||
|
import { getBookingById, type BookingStatus } from "./bookings.mock";
|
||||||
|
|
||||||
const BookingDetailPage = () => {
|
export default function BookingDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data: booking, isLoading } = useBooking(id ?? "");
|
const navigate = useNavigate();
|
||||||
|
const booking = id ? getBookingById(id) : undefined;
|
||||||
|
|
||||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
if (!booking) {
|
||||||
if (!booking)
|
return (
|
||||||
return <div className="text-sm text-red-600">Booking not found.</div>;
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Bookings", href: "/bookings" },
|
||||||
|
{ label: "Not found" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
Booking not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
The booking you're looking for doesn't exist or has been removed.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/bookings"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Bookings
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
Booking {booking.reference}
|
<Breadcrumbs
|
||||||
</h1>
|
items={[
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
{ label: "Bookings", href: "/bookings" },
|
||||||
<dt className="text-gray-500">Status</dt>
|
{ label: booking.reference },
|
||||||
<dd className="text-gray-900">{booking.status}</dd>
|
]}
|
||||||
<dt className="text-gray-500">Customer ID</dt>
|
/>
|
||||||
<dd className="text-gray-900">{booking.customerId}</dd>
|
|
||||||
<dt className="text-gray-500">Scheduled</dt>
|
{/* Header */}
|
||||||
<dd className="text-gray-900">
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
{new Date(booking.scheduledDate).toLocaleString()}
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
</dd>
|
<div className="flex items-center gap-4">
|
||||||
<dt className="text-gray-500">Total amount</dt>
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
<dd className="text-gray-900">{booking.totalAmount.toFixed(2)}</dd>
|
<Package className="h-8 w-8" />
|
||||||
</dl>
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
|
{booking.reference}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<span>{booking.customer}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span>{booking.requestedDate}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<StatusBadge status={booking.status} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<NewBookingPage
|
||||||
|
mode="edit"
|
||||||
|
booking={{
|
||||||
|
customerId: booking.customerId,
|
||||||
|
cargoType: booking.cargoType,
|
||||||
|
originStation: booking.originStation,
|
||||||
|
destinationStation: booking.destinationStation,
|
||||||
|
transportMode: booking.transportMode,
|
||||||
|
legs: booking.legs,
|
||||||
|
containerType: booking.containerType,
|
||||||
|
containerCount: booking.containerCount,
|
||||||
|
weightTons: booking.weightTons,
|
||||||
|
requestedDate: booking.requestedDate,
|
||||||
|
priority: booking.priority,
|
||||||
|
cargoDescription: booking.cargoDescription,
|
||||||
|
specialInstructions: booking.specialInstructions,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
Edit Booking
|
||||||
|
</button>
|
||||||
|
</NewBookingPage>
|
||||||
|
|
||||||
|
<DeleteBookingDialog
|
||||||
|
bookingReference={booking.reference}
|
||||||
|
onConfirm={() => navigate("/bookings")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</DeleteBookingDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route banner */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<RouteEndpoint
|
||||||
|
label="Origin"
|
||||||
|
station={booking.originStation}
|
||||||
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 text-[#33578D]">
|
||||||
|
<Train className="h-5 w-5" />
|
||||||
|
<ArrowRight className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<RouteEndpoint
|
||||||
|
label="Destination"
|
||||||
|
station={booking.destinationStation}
|
||||||
|
icon={<MapPin className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport Legs (multimodal only) */}
|
||||||
|
{booking.transportMode === "Multimodal" && booking.legs && booking.legs.length > 0 ? (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex items-center gap-2">
|
||||||
|
<Train className="h-5 w-5 text-[#33578D]" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Transport Legs
|
||||||
|
</h2>
|
||||||
|
<span className="rounded-full bg-[#33578D]/10 px-2 py-0.5 text-xs font-medium text-[#33578D]">
|
||||||
|
{booking.legs.length} legs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{booking.legs.map((leg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4 md:flex-row md:items-center md:justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
|
||||||
|
{i + 1}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
Leg {i + 1} · {leg.mode}
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{leg.from || "—"}
|
||||||
|
<ArrowRight className="mx-2 inline h-4 w-4 text-[#33578D]" />
|
||||||
|
{leg.to || "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Detail grid */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<DetailCard title="Customer & Cargo">
|
||||||
|
<DetailRow
|
||||||
|
icon={<User className="h-4 w-4" />}
|
||||||
|
label="Customer"
|
||||||
|
value={booking.customer}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
label="Cargo Type"
|
||||||
|
value={booking.cargoType}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
label="Container"
|
||||||
|
value={`${booking.containerCount} × ${booking.containerType}`}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Weight className="h-4 w-4" />}
|
||||||
|
label="Weight"
|
||||||
|
value={`${booking.weightTons} tons`}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Schedule & Mode">
|
||||||
|
<DetailRow
|
||||||
|
icon={<Train className="h-4 w-4" />}
|
||||||
|
label="Transport Mode"
|
||||||
|
value={booking.transportMode}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Calendar className="h-4 w-4" />}
|
||||||
|
label="Requested Date"
|
||||||
|
value={booking.requestedDate}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Flag className="h-4 w-4" />}
|
||||||
|
label="Priority"
|
||||||
|
value={booking.priority}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Cargo Description">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
||||||
|
<p className="leading-relaxed">{booking.cargoDescription}</p>
|
||||||
|
</div>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Special Instructions">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<StickyNote className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
||||||
|
<p className="leading-relaxed">{booking.specialInstructions}</p>
|
||||||
|
</div>
|
||||||
|
</DetailCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default BookingDetailPage;
|
function RouteEndpoint({
|
||||||
|
label,
|
||||||
|
station,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
station: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
{icon}
|
||||||
|
</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 (
|
||||||
|
<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-[#33578D]">{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: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,380 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { Button } from "@edr/ui-common";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import BookingTable from "../../components/bookings/BookingTable";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { useBookings } from "../../hooks/useBookings";
|
import NewBookingPage from "./NewBookingPage";
|
||||||
|
import DeleteBookingDialog from "./DeleteBookingDialog";
|
||||||
|
import { bookings, type BookingStatus } from "./bookings.mock";
|
||||||
|
|
||||||
const BookingsPage = () => {
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
const { data, isLoading } = useBookings();
|
|
||||||
const items = data?.items ?? [];
|
export default function BookingsPage() {
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
const total = bookings.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, total);
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => bookings.slice(start, end),
|
||||||
|
[start, end],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Bookings</h1>
|
<Breadcrumbs items={[{ label: "Bookings" }]} />
|
||||||
<Link to="/bookings/new">
|
|
||||||
<Button>New booking</Button>
|
{/* Header */}
|
||||||
</Link>
|
<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">
|
||||||
|
Bookings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
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-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search bookings..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewBookingPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Booking
|
||||||
|
</button>
|
||||||
|
</NewBookingPage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Bookings"
|
||||||
|
value={String(total)}
|
||||||
|
icon={<Package className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="In Transit"
|
||||||
|
value={String(
|
||||||
|
bookings.filter((b) => b.status === "In Transit").length,
|
||||||
|
)}
|
||||||
|
icon={<Truck className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Pending"
|
||||||
|
value={String(
|
||||||
|
bookings.filter((b) => b.status === "Pending").length,
|
||||||
|
)}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking 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">
|
||||||
|
Booking List
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Recent freight bookings and their status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[800px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Reference</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Customer</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Route</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Cargo</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{paginated.map((booking) => (
|
||||||
|
<tr
|
||||||
|
key={booking.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<Package className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{booking.reference}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{booking.requestedDate}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{booking.customer}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{booking.originStation}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-slate-400" />
|
||||||
|
<span>{booking.destinationStation}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p>{booking.cargoType}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{booking.containerCount} × {booking.containerType} ·{" "}
|
||||||
|
{booking.weightTons}t
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={booking.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/bookings/${booking.id}`}
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<NewBookingPage
|
||||||
|
mode="edit"
|
||||||
|
booking={{
|
||||||
|
customerId: booking.customerId,
|
||||||
|
cargoType: booking.cargoType,
|
||||||
|
originStation: booking.originStation,
|
||||||
|
destinationStation: booking.destinationStation,
|
||||||
|
transportMode: booking.transportMode,
|
||||||
|
legs: booking.legs,
|
||||||
|
containerType: booking.containerType,
|
||||||
|
containerCount: booking.containerCount,
|
||||||
|
weightTons: booking.weightTons,
|
||||||
|
requestedDate: booking.requestedDate,
|
||||||
|
priority: booking.priority,
|
||||||
|
cargoDescription: booking.cargoDescription,
|
||||||
|
specialInstructions: booking.specialInstructions,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewBookingPage>
|
||||||
|
|
||||||
|
<DeleteBookingDialog
|
||||||
|
bookingReference={booking.reference}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteBookingDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-sm text-gray-500">Loading…</div>
|
|
||||||
) : (
|
|
||||||
<BookingTable bookings={items} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label htmlFor="booking-page-size" className="font-medium text-slate-700">
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="booking-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default BookingsPage;
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 DeleteBookingDialogProps {
|
||||||
|
bookingReference: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteBookingDialog({
|
||||||
|
bookingReference,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeleteBookingDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
Cancel booking?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently cancel booking{" "}
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{bookingReference}
|
||||||
|
</span>
|
||||||
|
. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Keep booking</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
Cancel booking
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { ArrowRight, Calendar, MapPin, Package, Plus, Trash2, 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 { customers } from "../customers/customers.mock";
|
||||||
|
import type {
|
||||||
|
CargoType,
|
||||||
|
ContainerType,
|
||||||
|
LegMode,
|
||||||
|
Priority,
|
||||||
|
TransportLeg,
|
||||||
|
TransportMode,
|
||||||
|
} from "./bookings.mock";
|
||||||
|
|
||||||
|
export interface BookingFormData {
|
||||||
|
customerId?: number;
|
||||||
|
cargoType?: CargoType;
|
||||||
|
originStation?: string;
|
||||||
|
destinationStation?: string;
|
||||||
|
transportMode?: TransportMode;
|
||||||
|
legs?: TransportLeg[];
|
||||||
|
containerType?: ContainerType;
|
||||||
|
containerCount?: number;
|
||||||
|
weightTons?: number;
|
||||||
|
requestedDate?: string;
|
||||||
|
priority?: Priority;
|
||||||
|
cargoDescription?: string;
|
||||||
|
specialInstructions?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewBookingPageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
booking?: BookingFormData;
|
||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
||||||
|
|
||||||
|
const emptyLeg: TransportLeg = { mode: "Rail", from: "", to: "" };
|
||||||
|
|
||||||
|
export default function NewBookingPage({
|
||||||
|
mode = "create",
|
||||||
|
booking,
|
||||||
|
children,
|
||||||
|
}: NewBookingPageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const title = isEdit ? "Edit Freight Booking" : "New Freight Booking";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update an existing freight booking."
|
||||||
|
: "Create a freight booking with cargo and route details.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Submit Booking";
|
||||||
|
|
||||||
|
const [transportMode, setTransportMode] = useState<TransportMode>(
|
||||||
|
booking?.transportMode ?? "Rail",
|
||||||
|
);
|
||||||
|
const [legs, setLegs] = useState<TransportLeg[]>(
|
||||||
|
booking?.legs && booking.legs.length > 0
|
||||||
|
? booking.legs
|
||||||
|
: [{ ...emptyLeg }],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addLeg = () =>
|
||||||
|
setLegs((prev) => [...prev, { ...emptyLeg }]);
|
||||||
|
|
||||||
|
const removeLeg = (index: number) =>
|
||||||
|
setLegs((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
|
||||||
|
|
||||||
|
const updateLeg = (index: number, patch: Partial<TransportLeg>) =>
|
||||||
|
setLegs((prev) =>
|
||||||
|
prev.map((leg, i) => (i === index ? { ...leg, ...patch } : leg)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "New Booking"}</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">
|
||||||
|
{/* Customer */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Customer *</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={booking?.customerId ?? ""}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select customer
|
||||||
|
</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.company}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cargo Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Cargo Type *</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={booking?.cargoType ?? "Containerized"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Containerized</option>
|
||||||
|
<option>Bulk</option>
|
||||||
|
<option>Liquid</option>
|
||||||
|
<option>Refrigerated</option>
|
||||||
|
<option>Hazardous</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Origin Station */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Origin Station *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={booking?.originStation ?? ""}
|
||||||
|
placeholder="e.g. Addis Ababa"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination Station */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Destination Station *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={booking?.destinationStation ?? ""}
|
||||||
|
placeholder="e.g. Djibouti"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Transport Mode (controlled) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Transport Mode</Label>
|
||||||
|
<select
|
||||||
|
value={transportMode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTransportMode(e.target.value as TransportMode)
|
||||||
|
}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Rail</option>
|
||||||
|
<option>Truck</option>
|
||||||
|
<option>Multimodal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Container Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Container Type</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={booking?.containerType ?? "20FT"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>20FT</option>
|
||||||
|
<option>40FT</option>
|
||||||
|
<option>40HC</option>
|
||||||
|
<option>Reefer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Multimodal Transport Legs */}
|
||||||
|
{transportMode === "Multimodal" ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-[#33578D]/5 p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">
|
||||||
|
Transport Legs
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Define each segment of the multimodal journey.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addLeg}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-xl bg-[#33578D] px-3 py-1.5 text-xs font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add Leg
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{legs.map((leg, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="rounded-xl border border-slate-200 bg-white p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide text-[#33578D]">
|
||||||
|
Leg {i + 1}
|
||||||
|
</span>
|
||||||
|
{legs.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeLeg(i)}
|
||||||
|
aria-label={`Remove leg ${i + 1}`}
|
||||||
|
className="rounded-lg p-1 text-red-500 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Mode</Label>
|
||||||
|
<select
|
||||||
|
value={leg.mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLeg(i, {
|
||||||
|
mode: e.target.value as LegMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Rail</option>
|
||||||
|
<option>Truck</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">From</Label>
|
||||||
|
<Input
|
||||||
|
value={leg.from}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLeg(i, { from: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="Start station"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">To</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<ArrowRight className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
value={leg.to}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateLeg(i, { to: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="End station"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Container Count */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Container Count</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Package className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
defaultValue={booking?.containerCount ?? 1}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weight */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Weight (Tons)</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={booking?.weightTons ?? 0}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requested Date */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Requested Date</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
defaultValue={booking?.requestedDate ?? ""}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Priority</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={booking?.priority ?? "Normal"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Normal</option>
|
||||||
|
<option>High</option>
|
||||||
|
<option>Urgent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cargo Description */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Cargo Description</Label>
|
||||||
|
<Textarea
|
||||||
|
defaultValue={booking?.cargoDescription ?? ""}
|
||||||
|
placeholder="Describe the cargo..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Special Instructions */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Special Instructions</Label>
|
||||||
|
<Textarea
|
||||||
|
defaultValue={booking?.specialInstructions ?? ""}
|
||||||
|
placeholder="Any special handling instructions..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts
Normal file
131
apps/edr-freight-web/portal/src/pages/bookings/bookings.mock.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { customers } from "../customers/customers.mock";
|
||||||
|
|
||||||
|
export type BookingStatus =
|
||||||
|
| "Pending"
|
||||||
|
| "Confirmed"
|
||||||
|
| "In Transit"
|
||||||
|
| "Delivered"
|
||||||
|
| "Cancelled";
|
||||||
|
|
||||||
|
export type CargoType =
|
||||||
|
| "Containerized"
|
||||||
|
| "Bulk"
|
||||||
|
| "Liquid"
|
||||||
|
| "Refrigerated"
|
||||||
|
| "Hazardous";
|
||||||
|
|
||||||
|
export type TransportMode = "Rail" | "Truck" | "Multimodal";
|
||||||
|
|
||||||
|
export type LegMode = "Rail" | "Truck";
|
||||||
|
|
||||||
|
export type ContainerType = "20FT" | "40FT" | "40HC" | "Reefer";
|
||||||
|
|
||||||
|
export type Priority = "Normal" | "High" | "Urgent";
|
||||||
|
|
||||||
|
export interface TransportLeg {
|
||||||
|
mode: LegMode;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Booking {
|
||||||
|
id: number;
|
||||||
|
reference: string;
|
||||||
|
customerId: number;
|
||||||
|
customer: string;
|
||||||
|
cargoType: CargoType;
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
transportMode: TransportMode;
|
||||||
|
legs?: TransportLeg[];
|
||||||
|
containerType: ContainerType;
|
||||||
|
containerCount: number;
|
||||||
|
weightTons: number;
|
||||||
|
requestedDate: string;
|
||||||
|
priority: Priority;
|
||||||
|
cargoDescription: string;
|
||||||
|
specialInstructions: string;
|
||||||
|
status: BookingStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stations = [
|
||||||
|
"Addis Ababa",
|
||||||
|
"Adama",
|
||||||
|
"Mojo",
|
||||||
|
"Awash",
|
||||||
|
"Mieso",
|
||||||
|
"Dire Dawa",
|
||||||
|
"Aysha",
|
||||||
|
"Ali Sabieh",
|
||||||
|
"Holhol",
|
||||||
|
"Djibouti City",
|
||||||
|
];
|
||||||
|
|
||||||
|
const cargoTypes: CargoType[] = [
|
||||||
|
"Containerized",
|
||||||
|
"Bulk",
|
||||||
|
"Liquid",
|
||||||
|
"Refrigerated",
|
||||||
|
"Hazardous",
|
||||||
|
];
|
||||||
|
const modes: TransportMode[] = ["Rail", "Truck", "Multimodal"];
|
||||||
|
const containerTypes: ContainerType[] = ["20FT", "40FT", "40HC", "Reefer"];
|
||||||
|
const statuses: BookingStatus[] = [
|
||||||
|
"Pending",
|
||||||
|
"Confirmed",
|
||||||
|
"In Transit",
|
||||||
|
"Delivered",
|
||||||
|
"Cancelled",
|
||||||
|
];
|
||||||
|
const priorities: Priority[] = ["Normal", "High", "Urgent"];
|
||||||
|
|
||||||
|
function pickStation(i: number, offset: number) {
|
||||||
|
return stations[(i + offset) % stations.length] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bookings: Booking[] = Array.from({ length: 22 }, (_, i) => {
|
||||||
|
const customer = customers[i % customers.length] as (typeof customers)[number];
|
||||||
|
const id = i + 1;
|
||||||
|
const requested = new Date(2026, 4, 1 + (i % 28));
|
||||||
|
const transportMode = modes[i % modes.length] as TransportMode;
|
||||||
|
const originStation = pickStation(i, 0);
|
||||||
|
const destinationStation = pickStation(i, 5);
|
||||||
|
const midStation = pickStation(i, 3);
|
||||||
|
const legs: TransportLeg[] | undefined =
|
||||||
|
transportMode === "Multimodal"
|
||||||
|
? [
|
||||||
|
{ mode: "Rail", from: originStation, to: midStation },
|
||||||
|
{ mode: "Truck", from: midStation, to: destinationStation },
|
||||||
|
]
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
reference: `BK-${String(2026000 + id).slice(-6)}`,
|
||||||
|
customerId: customer.id,
|
||||||
|
customer: customer.company,
|
||||||
|
cargoType: cargoTypes[i % cargoTypes.length] as CargoType,
|
||||||
|
originStation,
|
||||||
|
destinationStation,
|
||||||
|
transportMode,
|
||||||
|
legs,
|
||||||
|
containerType: containerTypes[i % containerTypes.length] as ContainerType,
|
||||||
|
containerCount: (i % 5) + 1,
|
||||||
|
weightTons: 10 + ((i * 3) % 40),
|
||||||
|
requestedDate: requested.toISOString().slice(0, 10),
|
||||||
|
priority: priorities[i % priorities.length] as Priority,
|
||||||
|
cargoDescription:
|
||||||
|
i % 3 === 0
|
||||||
|
? "Coffee beans, sealed sacks"
|
||||||
|
: i % 3 === 1
|
||||||
|
? "Construction materials"
|
||||||
|
: "General merchandise",
|
||||||
|
specialInstructions:
|
||||||
|
i % 2 === 0 ? "Handle with care" : "Standard handling required",
|
||||||
|
status: statuses[i % statuses.length] as BookingStatus,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getBookingById(id: number | string): Booking | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return bookings.find((b) => b.id === numericId);
|
||||||
|
}
|
||||||
@@ -1,34 +1,295 @@
|
|||||||
import { useParams } from "react-router-dom";
|
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 { useConsignment } from "../../hooks/useConsignments";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewConsignmentPage from "./NewConsignmentPage";
|
||||||
|
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
|
||||||
|
import {
|
||||||
|
getConsignmentById,
|
||||||
|
type ConsignmentStatus,
|
||||||
|
} from "./consignments.mock";
|
||||||
|
|
||||||
const ConsignmentDetailPage = () => {
|
export default function ConsignmentDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const { data, isLoading } = useConsignment(id ?? "");
|
const navigate = useNavigate();
|
||||||
|
const consignment = id ? getConsignmentById(id) : undefined;
|
||||||
|
|
||||||
if (isLoading) return <div className="text-sm text-gray-500">Loading…</div>;
|
if (!consignment) {
|
||||||
if (!data)
|
return (
|
||||||
return <div className="text-sm text-red-600">Consignment not found.</div>;
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<Breadcrumbs
|
||||||
|
items={[
|
||||||
|
{ label: "Consignments", href: "/consignments" },
|
||||||
|
{ label: "Not found" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="rounded-3xl bg-white p-8 text-center shadow-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
Consignment not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
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-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Consignments
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
Consignment {data.trackingNumber}
|
<Breadcrumbs
|
||||||
</h1>
|
items={[
|
||||||
<dl className="grid grid-cols-2 gap-2 text-sm">
|
{ label: "Consignments", href: "/consignments" },
|
||||||
<dt className="text-gray-500">Status</dt>
|
{ label: consignment.trackingNumber },
|
||||||
<dd className="text-gray-900">{data.status}</dd>
|
]}
|
||||||
<dt className="text-gray-500">Cargo</dt>
|
/>
|
||||||
<dd className="text-gray-900">{data.cargoType}</dd>
|
|
||||||
<dt className="text-gray-500">Origin</dt>
|
{/* Header */}
|
||||||
<dd className="text-gray-900">{data.originStation}</dd>
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
<dt className="text-gray-500">Destination</dt>
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||||
<dd className="text-gray-900">{data.destinationStation}</dd>
|
<div className="flex items-center gap-4">
|
||||||
<dt className="text-gray-500">Weight</dt>
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
<dd className="text-gray-900">{data.weightKg.toFixed(2)} kg</dd>
|
<Package className="h-8 w-8" />
|
||||||
</dl>
|
</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 className="h-3.5 w-3.5" />
|
||||||
|
Hazardous
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</h1>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
Edit Consignment
|
||||||
|
</button>
|
||||||
|
</NewConsignmentPage>
|
||||||
|
|
||||||
|
<DeleteConsignmentDialog
|
||||||
|
trackingNumber={consignment.trackingNumber}
|
||||||
|
onConfirm={() => navigate("/consignments")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</DeleteConsignmentDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route banner */}
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||||
|
<RouteEndpoint
|
||||||
|
label="Origin"
|
||||||
|
station={consignment.originStation}
|
||||||
|
/>
|
||||||
|
<ArrowRight className="h-5 w-5 text-[#33578D]" />
|
||||||
|
<RouteEndpoint
|
||||||
|
label="Destination"
|
||||||
|
station={consignment.destinationStation}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Detail grid */}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<DetailCard title="Cargo">
|
||||||
|
<DetailRow
|
||||||
|
icon={<Package className="h-4 w-4" />}
|
||||||
|
label="Cargo Type"
|
||||||
|
value={consignment.cargoType}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Hash className="h-4 w-4" />}
|
||||||
|
label="Pieces"
|
||||||
|
value={String(consignment.pieces)}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Weight className="h-4 w-4" />}
|
||||||
|
label="Weight"
|
||||||
|
value={`${consignment.weightKg.toLocaleString()} kg`}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Ruler className="h-4 w-4" />}
|
||||||
|
label="Volume"
|
||||||
|
value={`${consignment.volumeM3} m³`}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="References & Schedule">
|
||||||
|
<DetailRow
|
||||||
|
icon={<Building2 className="h-4 w-4" />}
|
||||||
|
label="Customer"
|
||||||
|
value={consignment.customer}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Hash className="h-4 w-4" />}
|
||||||
|
label="Booking"
|
||||||
|
value={consignment.bookingReference}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Calendar className="h-4 w-4" />}
|
||||||
|
label="Created"
|
||||||
|
value={consignment.createdAt}
|
||||||
|
/>
|
||||||
|
<DetailRow
|
||||||
|
icon={<Calendar className="h-4 w-4" />}
|
||||||
|
label="Estimated Delivery"
|
||||||
|
value={consignment.estimatedDelivery}
|
||||||
|
/>
|
||||||
|
</DetailCard>
|
||||||
|
|
||||||
|
<DetailCard title="Description">
|
||||||
|
<div className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<Package className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
||||||
|
<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 className="mt-0.5 h-4 w-4 text-[#33578D]" />
|
||||||
|
<p className="leading-relaxed">{consignment.specialHandling}</p>
|
||||||
|
</div>
|
||||||
|
</DetailCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ConsignmentDetailPage;
|
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-[#33578D]/10 text-[#33578D]">
|
||||||
|
<MapPin className="h-5 w-5" />
|
||||||
|
</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 (
|
||||||
|
<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-[#33578D]">{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,503 @@
|
|||||||
import ConsignmentTable from "../../components/consignments/ConsignmentTable";
|
import { useMemo, useState } from "react";
|
||||||
import { useConsignments } from "../../hooks/useConsignments";
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowRight,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Package,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
const ConsignmentsPage = () => {
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
const { data, isLoading } = useConsignments();
|
import NewConsignmentPage from "./NewConsignmentPage";
|
||||||
const items = data?.items ?? [];
|
import DeleteConsignmentDialog from "./DeleteConsignmentDialog";
|
||||||
|
import {
|
||||||
|
consignments,
|
||||||
|
type ConsignmentStatus,
|
||||||
|
} from "./consignments.mock";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
|
|
||||||
|
type FilterValue = "All" | ConsignmentStatus;
|
||||||
|
|
||||||
|
const FILTERS: FilterValue[] = [
|
||||||
|
"All",
|
||||||
|
"Pending",
|
||||||
|
"In Warehouse",
|
||||||
|
"In Transit",
|
||||||
|
"Delivered",
|
||||||
|
"Returned",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ConsignmentsPage() {
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
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 totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, total);
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[filtered, start, end],
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Consignments</h1>
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
{isLoading ? (
|
<Breadcrumbs items={[{ label: "Consignments" }]} />
|
||||||
<div className="text-sm text-gray-500">Loading…</div>
|
|
||||||
) : (
|
{/* Header */}
|
||||||
<ConsignmentTable consignments={items} />
|
<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">
|
||||||
|
Consignments
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
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-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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Search consignments..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewConsignmentPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Consignment
|
||||||
|
</button>
|
||||||
|
</NewConsignmentPage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Consignments"
|
||||||
|
value={String(consignments.length)}
|
||||||
|
icon={<Package className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="In Transit"
|
||||||
|
value={String(inTransitCount)}
|
||||||
|
icon={<Truck className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Delivered"
|
||||||
|
value={String(deliveredCount)}
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Hazardous"
|
||||||
|
value={String(hazmatCount)}
|
||||||
|
icon={<AlertTriangle className="h-5 w-5" />}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="rounded-3xl bg-white p-2 shadow-sm">
|
||||||
|
<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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
||||||
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</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">
|
||||||
|
Consignment List
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Cargo units and their current handling status.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[1000px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Tracking #</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Booking</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Customer</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Route</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Cargo</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{paginated.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-6 py-12 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No consignments match your filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginated.map((consignment) => (
|
||||||
|
<tr
|
||||||
|
key={consignment.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<Package className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="flex items-center gap-2 font-medium text-slate-900">
|
||||||
|
{consignment.trackingNumber}
|
||||||
|
{consignment.hazardous ? (
|
||||||
|
<span
|
||||||
|
title="Hazardous"
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-full bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold text-red-700"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
DG
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{consignment.createdAt}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{consignment.bookingReference}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{consignment.customer}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{consignment.originStation}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-slate-400" />
|
||||||
|
<span>{consignment.destinationStation}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p>{consignment.cargoType}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{consignment.pieces} pcs ·{" "}
|
||||||
|
{consignment.weightKg.toLocaleString()} kg ·{" "}
|
||||||
|
{consignment.volumeM3} m³
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={consignment.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/consignments/${consignment.id}`}
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<NewConsignmentPage
|
||||||
|
mode="edit"
|
||||||
|
consignment={{
|
||||||
|
trackingNumber: consignment.trackingNumber,
|
||||||
|
bookingId: consignment.bookingId,
|
||||||
|
bookingReference: consignment.bookingReference,
|
||||||
|
cargoType: consignment.cargoType,
|
||||||
|
description: consignment.description,
|
||||||
|
weightKg: consignment.weightKg,
|
||||||
|
volumeM3: consignment.volumeM3,
|
||||||
|
pieces: consignment.pieces,
|
||||||
|
hazardous: consignment.hazardous,
|
||||||
|
specialHandling: consignment.specialHandling,
|
||||||
|
status: consignment.status,
|
||||||
|
estimatedDelivery: consignment.estimatedDelivery,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewConsignmentPage>
|
||||||
|
|
||||||
|
<DeleteConsignmentDialog
|
||||||
|
trackingNumber={consignment.trackingNumber}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteConsignmentDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default ConsignmentsPage;
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label
|
||||||
|
htmlFor="consignment-page-size"
|
||||||
|
className="font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="consignment-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
tone?: "brand" | "danger";
|
||||||
|
}) {
|
||||||
|
const iconWrap =
|
||||||
|
tone === "danger"
|
||||||
|
? "bg-red-100 text-red-600"
|
||||||
|
: "bg-[#33578D]/10 text-[#33578D]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${iconWrap}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/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-[#33578D] focus:ring-[#33578D]/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-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
|
||||||
|
export type ConsignmentStatus =
|
||||||
|
| "Pending"
|
||||||
|
| "In Warehouse"
|
||||||
|
| "In Transit"
|
||||||
|
| "Delivered"
|
||||||
|
| "Returned";
|
||||||
|
|
||||||
|
export type ConsignmentCargo =
|
||||||
|
| "Containerized"
|
||||||
|
| "Bulk"
|
||||||
|
| "Liquid"
|
||||||
|
| "Refrigerated"
|
||||||
|
| "Hazardous"
|
||||||
|
| "General";
|
||||||
|
|
||||||
|
export interface Consignment {
|
||||||
|
id: number;
|
||||||
|
trackingNumber: string;
|
||||||
|
bookingId: number;
|
||||||
|
bookingReference: string;
|
||||||
|
customer: string;
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
cargoType: ConsignmentCargo;
|
||||||
|
description: string;
|
||||||
|
weightKg: number;
|
||||||
|
volumeM3: number;
|
||||||
|
pieces: number;
|
||||||
|
hazardous: boolean;
|
||||||
|
specialHandling: string;
|
||||||
|
status: ConsignmentStatus;
|
||||||
|
createdAt: string;
|
||||||
|
estimatedDelivery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cargoTypes: ConsignmentCargo[] = [
|
||||||
|
"Containerized",
|
||||||
|
"Bulk",
|
||||||
|
"Liquid",
|
||||||
|
"Refrigerated",
|
||||||
|
"Hazardous",
|
||||||
|
"General",
|
||||||
|
];
|
||||||
|
|
||||||
|
const statuses: ConsignmentStatus[] = [
|
||||||
|
"Pending",
|
||||||
|
"In Warehouse",
|
||||||
|
"In Transit",
|
||||||
|
"Delivered",
|
||||||
|
"Returned",
|
||||||
|
];
|
||||||
|
|
||||||
|
const descriptions = [
|
||||||
|
"Coffee beans, sealed sacks",
|
||||||
|
"Construction rebar bundles",
|
||||||
|
"Industrial machinery parts",
|
||||||
|
"Textile rolls",
|
||||||
|
"Bottled mineral water",
|
||||||
|
"Refined cooking oil",
|
||||||
|
"Frozen meat products",
|
||||||
|
"Pharmaceutical supplies",
|
||||||
|
"Cement bags",
|
||||||
|
"Electronics consignment",
|
||||||
|
"Automotive spare parts",
|
||||||
|
"Packaged food goods",
|
||||||
|
];
|
||||||
|
|
||||||
|
const handlings = [
|
||||||
|
"Keep dry. Stack max 3 high.",
|
||||||
|
"Fragile — handle with care.",
|
||||||
|
"Temperature-controlled (2–8°C).",
|
||||||
|
"Oversized load — escort required.",
|
||||||
|
"Hazmat class 3 — segregate.",
|
||||||
|
"Standard handling.",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const consignments: Consignment[] = Array.from({ length: 22 }, (_, i) => {
|
||||||
|
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
||||||
|
const id = i + 1;
|
||||||
|
const created = new Date(2026, 4, 1 + (i % 14));
|
||||||
|
const eta = new Date(created);
|
||||||
|
eta.setDate(eta.getDate() + 5 + (i % 5));
|
||||||
|
const cargoType = cargoTypes[i % cargoTypes.length] as ConsignmentCargo;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
trackingNumber: `CGM-${String(id).padStart(4, "0")}`,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
customer: booking.customer,
|
||||||
|
originStation: booking.originStation,
|
||||||
|
destinationStation: booking.destinationStation,
|
||||||
|
cargoType,
|
||||||
|
description: descriptions[i % descriptions.length] as string,
|
||||||
|
weightKg: 500 + (i * 137) % 9500,
|
||||||
|
volumeM3: Math.round(((i % 10) + 2) * 10) / 10,
|
||||||
|
pieces: (i % 12) + 1,
|
||||||
|
hazardous: cargoType === "Hazardous",
|
||||||
|
specialHandling: handlings[i % handlings.length] as string,
|
||||||
|
status: statuses[i % statuses.length] as ConsignmentStatus,
|
||||||
|
createdAt: created.toISOString().slice(0, 10),
|
||||||
|
estimatedDelivery: eta.toISOString().slice(0, 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getConsignmentById(
|
||||||
|
id: number | string,
|
||||||
|
): Consignment | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return consignments.find((c) => c.id === numericId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
Globe,
|
||||||
|
Mail,
|
||||||
|
MapPin,
|
||||||
|
Phone,
|
||||||
|
StickyNote,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
|
import { getCustomerById, type CustomerStatus } from "./customers.mock";
|
||||||
|
|
||||||
|
export default function CustomerDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const customer = id ? getCustomerById(id) : undefined;
|
||||||
|
|
||||||
|
if (!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">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">
|
||||||
|
Customer not found
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">
|
||||||
|
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-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to Customers
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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-[#33578D]/10 text-[#33578D]">
|
||||||
|
<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>ID #{customer.id}</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">
|
||||||
|
<NewCustomerPage
|
||||||
|
mode="edit"
|
||||||
|
customer={{
|
||||||
|
companyName: customer.company,
|
||||||
|
customerType: customer.customerType,
|
||||||
|
contactPerson: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone,
|
||||||
|
tinNumber: customer.tinNumber,
|
||||||
|
city: customer.city,
|
||||||
|
country: customer.country,
|
||||||
|
address: customer.address,
|
||||||
|
notes: customer.notes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
Edit Customer
|
||||||
|
</button>
|
||||||
|
</NewCustomerPage>
|
||||||
|
|
||||||
|
<DeleteCustomerDialog
|
||||||
|
customerName={customer.name}
|
||||||
|
onConfirm={() => navigate("/customers")}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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>
|
||||||
|
</DeleteCustomerDialog>
|
||||||
|
</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-[#33578D]" />
|
||||||
|
<p className="leading-relaxed">{customer.notes}</p>
|
||||||
|
</div>
|
||||||
|
</DetailCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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-[#33578D]">{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,46 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock3,
|
||||||
|
Eye,
|
||||||
|
Filter,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
User,
|
||||||
|
UserCheck,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewCustomerPage from "./NewCustomerPage";
|
||||||
|
import DeleteCustomerDialog from "./DeleteCustomerDialog";
|
||||||
|
import { customers, type CustomerStatus } from "./customers.mock";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
|
|
||||||
export default function CustomerPage() {
|
export default function CustomerPage() {
|
||||||
const customers = [
|
const [pageSize, setPageSize] = useState(10);
|
||||||
{
|
const [page, setPage] = useState(1);
|
||||||
id: 1,
|
|
||||||
name: 'Abel Tesfaye',
|
const total = customers.length;
|
||||||
email: 'abel@example.com',
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
company: 'Addis Logistics',
|
const safePage = Math.min(page, totalPages);
|
||||||
status: 'Active',
|
const start = (safePage - 1) * pageSize;
|
||||||
},
|
const end = Math.min(start + pageSize, total);
|
||||||
{
|
const paginated = useMemo(
|
||||||
id: 2,
|
() => customers.slice(start, end),
|
||||||
name: 'Sara Bekele',
|
[start, end],
|
||||||
email: 'sara@example.com',
|
);
|
||||||
company: 'Blue Nile Trading',
|
|
||||||
status: 'Pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'Henok Alemu',
|
|
||||||
email: 'henok@example.com',
|
|
||||||
company: 'Ethio Freight',
|
|
||||||
status: 'Inactive',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-50 p-6">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<div className="mx-auto max-w-7xl space-y-6">
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Customers" }]} />
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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 className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -37,16 +52,25 @@ export default function CustomerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
|
||||||
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-100">
|
<div className="relative w-full sm:w-80">
|
||||||
<Search className="h-4 w-4" />
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
Search
|
<input
|
||||||
</button>
|
type="search"
|
||||||
|
placeholder="Search customers..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="inline-flex items-center gap-2 rounded-2xl bg-slate-900 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-800">
|
<NewCustomerPage>
|
||||||
<Plus className="h-4 w-4" />
|
<button
|
||||||
Add Customer
|
type="button"
|
||||||
</button>
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Customer
|
||||||
|
</button>
|
||||||
|
</NewCustomerPage>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -83,7 +107,7 @@ export default function CustomerPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-100">
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
<Filter className="h-4 w-4" />
|
<Filter className="h-4 w-4" />
|
||||||
Filter
|
Filter
|
||||||
</button>
|
</button>
|
||||||
@@ -97,22 +121,20 @@ export default function CustomerPage() {
|
|||||||
<th className="px-6 py-4 font-medium">Company</th>
|
<th className="px-6 py-4 font-medium">Company</th>
|
||||||
<th className="px-6 py-4 font-medium">Email</th>
|
<th className="px-6 py-4 font-medium">Email</th>
|
||||||
<th className="px-6 py-4 font-medium">Status</th>
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
<th className="px-6 py-4 font-medium text-right">
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{customers.map((customer) => (
|
{paginated.map((customer) => (
|
||||||
<tr
|
<tr
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
className="border-t border-slate-100 transition hover:bg-slate-50"
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
>
|
>
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-100">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
<User className="h-5 w-5 text-slate-600" />
|
<User className="h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -140,17 +162,44 @@ export default function CustomerPage() {
|
|||||||
|
|
||||||
<td className="px-6 py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<button className="rounded-xl border border-slate-200 p-2 text-slate-600 hover:bg-slate-100">
|
<Link
|
||||||
|
to={`/customers/${customer.id}`}
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
<button className="rounded-xl border border-slate-200 p-2 text-slate-600 hover:bg-slate-100">
|
<NewCustomerPage
|
||||||
<Pencil className="h-4 w-4" />
|
mode="edit"
|
||||||
</button>
|
customer={{
|
||||||
|
companyName: customer.company,
|
||||||
|
customerType: customer.customerType,
|
||||||
|
contactPerson: customer.name,
|
||||||
|
email: customer.email,
|
||||||
|
phone: customer.phone,
|
||||||
|
tinNumber: customer.tinNumber,
|
||||||
|
city: customer.city,
|
||||||
|
country: customer.country,
|
||||||
|
address: customer.address,
|
||||||
|
notes: customer.notes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewCustomerPage>
|
||||||
|
|
||||||
<button className="rounded-xl border border-red-200 p-2 text-red-600 hover:bg-red-50">
|
<DeleteCustomerDialog customerName={customer.name}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteCustomerDialog>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -158,12 +207,114 @@ export default function CustomerPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label htmlFor="page-size" className="font-medium text-slate-700">
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
title,
|
title,
|
||||||
value,
|
value,
|
||||||
@@ -174,16 +325,14 @@ function StatCard({
|
|||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl bg-white p-6 shadow-sm">
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">{title}</p>
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
<h3 className="mt-2 text-3xl font-bold text-slate-900">
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
{value}
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-100 text-slate-700">
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
{icon}
|
{icon}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,11 +340,11 @@ function StatCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusBadge({ status }: { status: string }) {
|
function StatusBadge({ status }: { status: CustomerStatus }) {
|
||||||
const styles: Record<string, string> = {
|
const styles: Record<CustomerStatus, string> = {
|
||||||
Active: 'bg-emerald-100 text-emerald-700',
|
Active: "bg-emerald-100 text-emerald-700",
|
||||||
Pending: 'bg-amber-100 text-amber-700',
|
Pending: "bg-amber-100 text-amber-700",
|
||||||
Inactive: 'bg-red-100 text-red-700',
|
Inactive: "bg-red-100 text-red-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -206,16 +355,3 @@ function StatusBadge({ status }: { status: string }) {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
import {
|
|
||||||
Clock3,
|
|
||||||
Eye,
|
|
||||||
Filter,
|
|
||||||
Pencil,
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Trash2,
|
|
||||||
User,
|
|
||||||
UserCheck,
|
|
||||||
Users,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 DeleteCustomerDialogProps {
|
||||||
|
customerName: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteCustomerDialog({
|
||||||
|
customerName,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeleteCustomerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/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-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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 DeleteDocumentDialogProps {
|
||||||
|
documentName: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteDocumentDialog({
|
||||||
|
documentName,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeleteDocumentDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
Delete document?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently delete{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{documentName}</span>{" "}
|
||||||
|
and remove it from object storage. 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Clock,
|
||||||
|
Download,
|
||||||
|
Eye,
|
||||||
|
File,
|
||||||
|
FileImage,
|
||||||
|
FileSpreadsheet,
|
||||||
|
FileText,
|
||||||
|
Filter,
|
||||||
|
HardDrive,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewDocumentPage from "./NewDocumentPage";
|
||||||
|
import DeleteDocumentDialog from "./DeleteDocumentDialog";
|
||||||
|
import {
|
||||||
|
documents,
|
||||||
|
formatBytes,
|
||||||
|
type DocumentFormat,
|
||||||
|
type DocumentRecord,
|
||||||
|
type DocumentStatus,
|
||||||
|
} from "./documents.mock";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
|
|
||||||
|
type FilterValue = "All" | DocumentStatus;
|
||||||
|
type ViewMode = "grid" | "table";
|
||||||
|
|
||||||
|
const FILTERS: FilterValue[] = [
|
||||||
|
"All",
|
||||||
|
"Draft",
|
||||||
|
"Pending Review",
|
||||||
|
"Approved",
|
||||||
|
"Rejected",
|
||||||
|
"Expired",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function DocumentsPage() {
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [filter, setFilter] = useState<FilterValue>("All");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [view, setView] = useState<ViewMode>("table");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return documents.filter((d) => {
|
||||||
|
if (filter !== "All" && d.status !== filter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
d.name.toLowerCase().includes(q) ||
|
||||||
|
d.type.toLowerCase().includes(q) ||
|
||||||
|
d.linkedReference.toLowerCase().includes(q) ||
|
||||||
|
d.uploadedBy.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [filter, query]);
|
||||||
|
|
||||||
|
const total = filtered.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, total);
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[filtered, start, end],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalSize = documents.reduce((sum, d) => sum + d.sizeBytes, 0);
|
||||||
|
const approvedCount = documents.filter((d) => d.status === "Approved").length;
|
||||||
|
const pendingCount = documents.filter(
|
||||||
|
(d) => d.status === "Pending Review",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Documents" }]} />
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
Documents
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Manage freight documents linked to bookings, consignments, and
|
||||||
|
invoices.
|
||||||
|
</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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Search documents..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewDocumentPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Upload Document
|
||||||
|
</button>
|
||||||
|
</NewDocumentPage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Documents"
|
||||||
|
value={String(documents.length)}
|
||||||
|
icon={<FileText className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Approved"
|
||||||
|
value={String(approvedCount)}
|
||||||
|
icon={<CheckCircle2 className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Pending Review"
|
||||||
|
value={String(pendingCount)}
|
||||||
|
icon={<Clock className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Storage Used"
|
||||||
|
value={formatBytes(totalSize)}
|
||||||
|
icon={<HardDrive className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs + view toggle */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl bg-white p-2 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{FILTERS.map((f) => {
|
||||||
|
const isActive = f === filter;
|
||||||
|
const count =
|
||||||
|
f === "All"
|
||||||
|
? documents.length
|
||||||
|
: documents.filter((d) => d.status === f).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFilter(f);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
||||||
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
|
||||||
|
<ViewToggleButton
|
||||||
|
active={view === "grid"}
|
||||||
|
onClick={() => setView("grid")}
|
||||||
|
label="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Grid</span>
|
||||||
|
</ViewToggleButton>
|
||||||
|
<ViewToggleButton
|
||||||
|
active={view === "table"}
|
||||||
|
onClick={() => setView("table")}
|
||||||
|
label="Table view"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Table</span>
|
||||||
|
</ViewToggleButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty / Grid / Table */}
|
||||||
|
{paginated.length === 0 ? (
|
||||||
|
<div className="rounded-3xl bg-white p-12 text-center shadow-sm">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No documents match your filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : view === "grid" ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{paginated.map((doc) => (
|
||||||
|
<DocumentCard key={doc.id} doc={doc} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DocumentTable documents={paginated} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length > 0 ? (
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ViewToggleButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#33578D] shadow-sm"
|
||||||
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormatIcon({ format }: { format: DocumentFormat }) {
|
||||||
|
const className = "h-5 w-5 text-[#33578D]";
|
||||||
|
if (format === "PDF") return <FileText className={className} />;
|
||||||
|
if (format === "DOCX") return <FileText className={className} />;
|
||||||
|
if (format === "XLSX") return <FileSpreadsheet className={className} />;
|
||||||
|
if (format === "PNG" || format === "JPG")
|
||||||
|
return <FileImage className={className} />;
|
||||||
|
return <File className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentCard({ doc }: { doc: DocumentRecord }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10">
|
||||||
|
<FormatIcon format={doc.format} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold text-slate-900">
|
||||||
|
{doc.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">{doc.type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={doc.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<MetaRow label="Linked to" value={`${doc.linkedType} · ${doc.linkedReference}`} />
|
||||||
|
<MetaRow label="Format" value={doc.format} />
|
||||||
|
<MetaRow label="Size" value={formatBytes(doc.sizeBytes)} />
|
||||||
|
<MetaRow label="Uploaded" value={doc.uploadedAt} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">By {doc.uploadedBy}</p>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Preview"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Download"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<NewDocumentPage
|
||||||
|
mode="edit"
|
||||||
|
document={{
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
linkedType: doc.linkedType,
|
||||||
|
linkedReference: doc.linkedReference,
|
||||||
|
notes: doc.notes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewDocumentPage>
|
||||||
|
<DeleteDocumentDialog documentName={doc.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteDocumentDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetaRow({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
<p className="mt-0.5 text-sm font-medium text-slate-900">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocumentTable({ documents: rows }: { documents: DocumentRecord[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-100 px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-slate-900">
|
||||||
|
Document Library
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
All freight documents stored in the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[1000px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Document</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Type</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Linked To</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Size</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Uploaded</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{rows.map((doc) => (
|
||||||
|
<tr
|
||||||
|
key={doc.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10">
|
||||||
|
<FormatIcon format={doc.format} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate font-medium text-slate-900">
|
||||||
|
{doc.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{doc.format} · By {doc.uploadedBy}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">{doc.type}</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p>{doc.linkedReference}</p>
|
||||||
|
<p className="text-xs text-slate-500">{doc.linkedType}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{formatBytes(doc.sizeBytes)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{doc.uploadedAt}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={doc.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Preview"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Download"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<NewDocumentPage
|
||||||
|
mode="edit"
|
||||||
|
document={{
|
||||||
|
name: doc.name,
|
||||||
|
type: doc.type,
|
||||||
|
status: doc.status,
|
||||||
|
linkedType: doc.linkedType,
|
||||||
|
linkedReference: doc.linkedReference,
|
||||||
|
notes: doc.notes,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewDocumentPage>
|
||||||
|
<DeleteDocumentDialog documentName={doc.name}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteDocumentDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label
|
||||||
|
htmlFor="document-page-size"
|
||||||
|
className="font-medium text-slate-700"
|
||||||
|
>
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="document-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: { status: DocumentStatus }) {
|
||||||
|
const styles: Record<DocumentStatus, string> = {
|
||||||
|
Draft: "bg-slate-100 text-slate-600",
|
||||||
|
"Pending Review": "bg-amber-100 text-amber-700",
|
||||||
|
Approved: "bg-emerald-100 text-emerald-700",
|
||||||
|
Rejected: "bg-red-100 text-red-700",
|
||||||
|
Expired: "bg-slate-200 text-slate-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { FileUp, Hash } from "lucide-react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
import { consignments } from "../consignments/consignments.mock";
|
||||||
|
import { customers } from "../customers/customers.mock";
|
||||||
|
import type {
|
||||||
|
DocumentLinkType,
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentType,
|
||||||
|
} from "./documents.mock";
|
||||||
|
|
||||||
|
export interface DocumentFormData {
|
||||||
|
name?: string;
|
||||||
|
type?: DocumentType;
|
||||||
|
status?: DocumentStatus;
|
||||||
|
linkedType?: DocumentLinkType;
|
||||||
|
linkedReference?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewDocumentPageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
document?: DocumentFormData;
|
||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
||||||
|
|
||||||
|
export default function NewDocumentPage({
|
||||||
|
mode = "create",
|
||||||
|
document,
|
||||||
|
children,
|
||||||
|
}: NewDocumentPageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const title = isEdit ? "Edit Document" : "Upload Document";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update document metadata."
|
||||||
|
: "Upload a freight document and link it to a booking, consignment, or invoice.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Upload";
|
||||||
|
|
||||||
|
const [linkedType, setLinkedType] = useState<DocumentLinkType>(
|
||||||
|
document?.linkedType ?? "Booking",
|
||||||
|
);
|
||||||
|
const [fileName, setFileName] = useState<string>("");
|
||||||
|
|
||||||
|
const referenceOptions = (() => {
|
||||||
|
if (linkedType === "Booking") {
|
||||||
|
return bookings.map((b) => ({
|
||||||
|
value: b.reference,
|
||||||
|
label: `${b.reference} — ${b.customer}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (linkedType === "Consignment") {
|
||||||
|
return consignments.map((c) => ({
|
||||||
|
value: c.trackingNumber,
|
||||||
|
label: `${c.trackingNumber} — ${c.customer}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (linkedType === "Customer") {
|
||||||
|
return customers.map((c) => ({
|
||||||
|
value: c.company,
|
||||||
|
label: c.company,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return [] as Array<{ value: string; label: string }>;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "Upload Document"}</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">
|
||||||
|
{/* File picker — drop zone */}
|
||||||
|
{!isEdit ? (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Label>File</Label>
|
||||||
|
<label
|
||||||
|
htmlFor="document-file-input"
|
||||||
|
className="mt-1 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-slate-200 bg-[#33578D]/5 p-8 text-center transition hover:border-[#33578D]/40 hover:bg-[#33578D]/10"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<FileUp className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">
|
||||||
|
{fileName || "Click to choose a file or drag it here"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
PDF, DOCX, XLSX, PNG, JPG · max 25 MB
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
id="document-file-input"
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.docx,.xlsx,.png,.jpg,.jpeg"
|
||||||
|
onChange={(e) =>
|
||||||
|
setFileName(e.target.files?.[0]?.name ?? "")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Document Name */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Document Name *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Hash className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={document?.name ?? ""}
|
||||||
|
placeholder="e.g. bill-of-lading-bk-026003.pdf"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Document Type */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Document Type *</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={document?.type ?? "Bill of Lading"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Bill of Lading</option>
|
||||||
|
<option>Commercial Invoice</option>
|
||||||
|
<option>Packing List</option>
|
||||||
|
<option>Customs Declaration</option>
|
||||||
|
<option>Certificate of Origin</option>
|
||||||
|
<option>Insurance Certificate</option>
|
||||||
|
<option>Delivery Receipt</option>
|
||||||
|
<option>Proof of Delivery</option>
|
||||||
|
<option>Contract</option>
|
||||||
|
<option>Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Linked To */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Linked To</Label>
|
||||||
|
<select
|
||||||
|
value={linkedType}
|
||||||
|
onChange={(e) =>
|
||||||
|
setLinkedType(e.target.value as DocumentLinkType)
|
||||||
|
}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Booking</option>
|
||||||
|
<option>Consignment</option>
|
||||||
|
<option>Shipment</option>
|
||||||
|
<option>Customer</option>
|
||||||
|
<option>Invoice</option>
|
||||||
|
<option>None</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Reference</Label>
|
||||||
|
{referenceOptions.length > 0 ? (
|
||||||
|
<select
|
||||||
|
defaultValue={document?.linkedReference ?? ""}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
Select {linkedType.toLowerCase()}
|
||||||
|
</option>
|
||||||
|
{referenceOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
defaultValue={document?.linkedReference ?? ""}
|
||||||
|
placeholder={
|
||||||
|
linkedType === "None"
|
||||||
|
? "Not linked"
|
||||||
|
: `Enter ${linkedType.toLowerCase()} reference`
|
||||||
|
}
|
||||||
|
disabled={linkedType === "None"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={document?.status ?? "Draft"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>Draft</option>
|
||||||
|
<option>Pending Review</option>
|
||||||
|
<option>Approved</option>
|
||||||
|
<option>Rejected</option>
|
||||||
|
<option>Expired</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
defaultValue={document?.notes ?? ""}
|
||||||
|
placeholder="Any extra context for this document..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
import { consignments } from "../consignments/consignments.mock";
|
||||||
|
|
||||||
|
export type DocumentType =
|
||||||
|
| "Bill of Lading"
|
||||||
|
| "Commercial Invoice"
|
||||||
|
| "Packing List"
|
||||||
|
| "Customs Declaration"
|
||||||
|
| "Certificate of Origin"
|
||||||
|
| "Insurance Certificate"
|
||||||
|
| "Delivery Receipt"
|
||||||
|
| "Proof of Delivery"
|
||||||
|
| "Contract"
|
||||||
|
| "Other";
|
||||||
|
|
||||||
|
export type DocumentFormat = "PDF" | "DOCX" | "XLSX" | "PNG" | "JPG";
|
||||||
|
|
||||||
|
export type DocumentStatus =
|
||||||
|
| "Draft"
|
||||||
|
| "Pending Review"
|
||||||
|
| "Approved"
|
||||||
|
| "Rejected"
|
||||||
|
| "Expired";
|
||||||
|
|
||||||
|
export type DocumentLinkType =
|
||||||
|
| "Booking"
|
||||||
|
| "Consignment"
|
||||||
|
| "Shipment"
|
||||||
|
| "Customer"
|
||||||
|
| "Invoice"
|
||||||
|
| "None";
|
||||||
|
|
||||||
|
export interface DocumentRecord {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: DocumentType;
|
||||||
|
format: DocumentFormat;
|
||||||
|
sizeBytes: number;
|
||||||
|
linkedType: DocumentLinkType;
|
||||||
|
linkedReference: string;
|
||||||
|
uploadedBy: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
status: DocumentStatus;
|
||||||
|
notes: string;
|
||||||
|
/** Object key — meant for the future MinIO bucket. */
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const types: DocumentType[] = [
|
||||||
|
"Bill of Lading",
|
||||||
|
"Commercial Invoice",
|
||||||
|
"Packing List",
|
||||||
|
"Customs Declaration",
|
||||||
|
"Certificate of Origin",
|
||||||
|
"Insurance Certificate",
|
||||||
|
"Delivery Receipt",
|
||||||
|
"Proof of Delivery",
|
||||||
|
"Contract",
|
||||||
|
"Other",
|
||||||
|
];
|
||||||
|
|
||||||
|
const formats: DocumentFormat[] = ["PDF", "DOCX", "XLSX", "PNG", "JPG"];
|
||||||
|
const statuses: DocumentStatus[] = [
|
||||||
|
"Draft",
|
||||||
|
"Pending Review",
|
||||||
|
"Approved",
|
||||||
|
"Rejected",
|
||||||
|
"Expired",
|
||||||
|
];
|
||||||
|
const uploaders = [
|
||||||
|
"John Doe",
|
||||||
|
"Sarah Bekele",
|
||||||
|
"Michael Chen",
|
||||||
|
"Aisha Mohamed",
|
||||||
|
"Daniel Worku",
|
||||||
|
];
|
||||||
|
|
||||||
|
function fileNameFor(type: DocumentType, ref: string, format: DocumentFormat) {
|
||||||
|
const slug = type.toLowerCase().replace(/\s+/g, "-");
|
||||||
|
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));
|
||||||
|
|
||||||
|
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 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}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getDocumentById(id: number | string): DocumentRecord | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return documents.find((d) => d.id === numericId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 DeleteShipmentDialogProps {
|
||||||
|
shipmentReference: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteShipmentDialog({
|
||||||
|
shipmentReference,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeleteShipmentDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">
|
||||||
|
Remove shipment?
|
||||||
|
</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
This will permanently remove shipment{" "}
|
||||||
|
<span className="font-semibold text-slate-900">
|
||||||
|
{shipmentReference}
|
||||||
|
</span>{" "}
|
||||||
|
from tracking. 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Calendar, MapPin } 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 { bookings } from "../bookings/bookings.mock";
|
||||||
|
import type { ShipmentMode, ShipmentStatus } from "./shipments.mock";
|
||||||
|
|
||||||
|
export interface ShipmentFormData {
|
||||||
|
bookingId?: number;
|
||||||
|
bookingReference?: string;
|
||||||
|
originStation?: string;
|
||||||
|
destinationStation?: string;
|
||||||
|
mode?: ShipmentMode;
|
||||||
|
status?: ShipmentStatus;
|
||||||
|
currentLocation?: string;
|
||||||
|
eta?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NewShipmentPageProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
shipment?: ShipmentFormData;
|
||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20";
|
||||||
|
|
||||||
|
export default function NewShipmentPage({
|
||||||
|
mode = "create",
|
||||||
|
shipment,
|
||||||
|
children,
|
||||||
|
}: NewShipmentPageProps = {}) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const title = isEdit ? "Edit Shipment" : "New Shipment";
|
||||||
|
const description = isEdit
|
||||||
|
? "Update shipment tracking information."
|
||||||
|
: "Create a new shipment for real-time tracking.";
|
||||||
|
const submitLabel = isEdit ? "Save Changes" : "Create Shipment";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
{children ?? <Button>{isEdit ? "Edit" : "New Shipment"}</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">
|
||||||
|
{/* Freight Booking */}
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<Label>Freight Booking *</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={shipment?.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>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Status</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={shipment?.status ?? "In Transit"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option>In Transit</option>
|
||||||
|
<option>Delivered</option>
|
||||||
|
<option>Delayed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Transport Mode</Label>
|
||||||
|
<select
|
||||||
|
defaultValue={shipment?.mode ?? "rail"}
|
||||||
|
className={selectClass}
|
||||||
|
>
|
||||||
|
<option value="rail">Rail</option>
|
||||||
|
<option value="truck">Truck</option>
|
||||||
|
<option value="multimodal">Multimodal</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Origin */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Origin Station</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={shipment?.originStation ?? ""}
|
||||||
|
placeholder="Auto-filled from booking"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Destination */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Destination Station</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={shipment?.destinationStation ?? ""}
|
||||||
|
placeholder="Auto-filled from booking"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ETA */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Estimated Arrival</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
defaultValue={shipment?.eta ?? ""}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Location */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Current Location</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-4 w-4 text-slate-400" />
|
||||||
|
<Input
|
||||||
|
defaultValue={shipment?.currentLocation ?? ""}
|
||||||
|
placeholder="e.g. Dire Dawa Yard"
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button className="bg-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,462 @@
|
|||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Button, FormField } from "@edr/ui-common";
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
Building2,
|
||||||
|
Clock,
|
||||||
|
LayoutGrid,
|
||||||
|
List,
|
||||||
|
MapPin,
|
||||||
|
Pencil,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Train,
|
||||||
|
Trash2,
|
||||||
|
Truck,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
import TrackingTimeline from "../../components/tracking/TrackingTimeline";
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
import { useTracking } from "../../hooks/useTracking";
|
import NewShipmentPage from "./NewShipmentPage";
|
||||||
|
import DeleteShipmentDialog from "./DeleteShipmentDialog";
|
||||||
|
import {
|
||||||
|
shipments,
|
||||||
|
type Shipment,
|
||||||
|
type ShipmentMode,
|
||||||
|
type ShipmentStatus,
|
||||||
|
} from "./shipments.mock";
|
||||||
|
|
||||||
const TrackingPage = () => {
|
type FilterValue = "All" | ShipmentStatus;
|
||||||
const [consignmentId, setConsignmentId] = useState("");
|
type ViewMode = "grid" | "table";
|
||||||
const [activeId, setActiveId] = useState("");
|
|
||||||
const { data, isFetching } = useTracking(activeId);
|
const FILTERS: FilterValue[] = ["All", "In Transit", "Delivered", "Delayed"];
|
||||||
|
|
||||||
|
export default function TrackingPage() {
|
||||||
|
const [filter, setFilter] = useState<FilterValue>("All");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [view, setView] = useState<ViewMode>("grid");
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return shipments.filter((s) => {
|
||||||
|
if (filter !== "All" && s.status !== filter) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
s.reference.toLowerCase().includes(q) ||
|
||||||
|
s.bookingReference.toLowerCase().includes(q) ||
|
||||||
|
s.customer.toLowerCase().includes(q) ||
|
||||||
|
s.originStation.toLowerCase().includes(q) ||
|
||||||
|
s.destinationStation.toLowerCase().includes(q) ||
|
||||||
|
s.currentLocation.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [filter, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex max-w-2xl flex-col gap-4">
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Tracking</h1>
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
<div className="flex items-end gap-3">
|
<Breadcrumbs items={[{ label: "Tracking" }]} />
|
||||||
<FormField
|
|
||||||
label="Consignment ID"
|
{/* Header */}
|
||||||
value={consignmentId}
|
<div className="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
onChange={(event) => setConsignmentId(event.target.value)}
|
<div>
|
||||||
placeholder="UUID"
|
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
|
||||||
/>
|
Shipment Tracking
|
||||||
<Button onClick={() => setActiveId(consignmentId)}>Look up</Button>
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Real-time cargo and shipment monitoring.
|
||||||
|
</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 shipments..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewShipmentPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Shipment
|
||||||
|
</button>
|
||||||
|
</NewShipmentPage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs + view toggle */}
|
||||||
|
<div className="flex flex-col gap-3 rounded-3xl bg-white p-2 shadow-sm md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{FILTERS.map((f) => {
|
||||||
|
const isActive = f === filter;
|
||||||
|
const count =
|
||||||
|
f === "All"
|
||||||
|
? shipments.length
|
||||||
|
: shipments.filter((s) => s.status === f).length;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
||||||
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 self-start rounded-xl bg-slate-100 p-1 md:self-auto">
|
||||||
|
<ViewToggleButton
|
||||||
|
active={view === "grid"}
|
||||||
|
onClick={() => setView("grid")}
|
||||||
|
label="Grid view"
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Grid</span>
|
||||||
|
</ViewToggleButton>
|
||||||
|
<ViewToggleButton
|
||||||
|
active={view === "table"}
|
||||||
|
onClick={() => setView("table")}
|
||||||
|
label="Table view"
|
||||||
|
>
|
||||||
|
<List className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Table</span>
|
||||||
|
</ViewToggleButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty / Grid / Table */}
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-3xl bg-white p-12 text-center shadow-sm">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No shipments match your filters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : view === "grid" ? (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{filtered.map((shipment) => (
|
||||||
|
<ShipmentCard key={shipment.id} shipment={shipment} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ShipmentTable shipments={filtered} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isFetching ? (
|
|
||||||
<div className="text-sm text-gray-500">Loading…</div>
|
|
||||||
) : (
|
|
||||||
<TrackingTimeline events={data ?? []} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export default TrackingPage;
|
function ViewToggleButton({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
label: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={
|
||||||
|
active
|
||||||
|
? "inline-flex items-center gap-2 rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-[#33578D] shadow-sm"
|
||||||
|
: "inline-flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm font-medium text-slate-600 transition hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShipmentCard({ shipment }: { shipment: Shipment }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 rounded-3xl bg-white p-5 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-base font-bold text-slate-900">
|
||||||
|
{shipment.reference}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Booking {shipment.bookingReference}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={shipment.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Route */}
|
||||||
|
<div className="flex items-center justify-between rounded-2xl bg-[#33578D]/5 p-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
From
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{shipment.originStation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-5 w-5 text-[#33578D]" />
|
||||||
|
<div className="text-sm text-right">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||||
|
To
|
||||||
|
</p>
|
||||||
|
<p className="font-semibold text-slate-900">
|
||||||
|
{shipment.destinationStation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-500">
|
||||||
|
<span>Progress</span>
|
||||||
|
<span className="font-medium text-slate-700">
|
||||||
|
{shipment.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#33578D] transition-all"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="space-y-2 text-sm text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building2 className="h-4 w-4 text-[#33578D]" />
|
||||||
|
<span>{shipment.customer}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ModeIcon mode={shipment.mode} />
|
||||||
|
<span className="capitalize">{shipment.mode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4 text-[#33578D]" />
|
||||||
|
<span>{shipment.currentLocation}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
|
<Clock className="h-3.5 w-3.5" />
|
||||||
|
<span>
|
||||||
|
Updated {shipment.lastUpdate} · ETA {shipment.eta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center justify-end gap-2 border-t border-slate-100 pt-3">
|
||||||
|
<NewShipmentPage
|
||||||
|
mode="edit"
|
||||||
|
shipment={{
|
||||||
|
bookingId: shipment.bookingId,
|
||||||
|
bookingReference: shipment.bookingReference,
|
||||||
|
originStation: shipment.originStation,
|
||||||
|
destinationStation: shipment.destinationStation,
|
||||||
|
mode: shipment.mode,
|
||||||
|
status: shipment.status,
|
||||||
|
currentLocation: shipment.currentLocation,
|
||||||
|
eta: shipment.eta,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-xl border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</NewShipmentPage>
|
||||||
|
|
||||||
|
<DeleteShipmentDialog shipmentReference={shipment.reference}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1 rounded-xl border border-red-200 px-3 py-1.5 text-xs font-medium text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</DeleteShipmentDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShipmentTable({ shipments: rows }: { shipments: Shipment[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-hidden rounded-3xl bg-white shadow-sm">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[1000px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Shipment</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Booking</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Route</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Mode</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Progress</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Current Location</th>
|
||||||
|
<th className="px-6 py-4 font-medium">ETA</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{rows.map((shipment) => (
|
||||||
|
<tr
|
||||||
|
key={shipment.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<Truck className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{shipment.reference}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{shipment.customer}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{shipment.bookingReference}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{shipment.originStation}</span>
|
||||||
|
<ArrowRight className="h-3.5 w-3.5 text-slate-400" />
|
||||||
|
<span>{shipment.destinationStation}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-2 text-sm capitalize text-slate-700">
|
||||||
|
<ModeIcon mode={shipment.mode} />
|
||||||
|
{shipment.mode}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={shipment.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex w-32 items-center gap-2">
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-[#33578D]"
|
||||||
|
style={{ width: `${shipment.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-slate-600">
|
||||||
|
{shipment.progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<MapPin className="h-3.5 w-3.5 text-[#33578D]" />
|
||||||
|
{shipment.currentLocation}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{shipment.eta}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<NewShipmentPage
|
||||||
|
mode="edit"
|
||||||
|
shipment={{
|
||||||
|
bookingId: shipment.bookingId,
|
||||||
|
bookingReference: shipment.bookingReference,
|
||||||
|
originStation: shipment.originStation,
|
||||||
|
destinationStation: shipment.destinationStation,
|
||||||
|
mode: shipment.mode,
|
||||||
|
status: shipment.status,
|
||||||
|
currentLocation: shipment.currentLocation,
|
||||||
|
eta: shipment.eta,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewShipmentPage>
|
||||||
|
|
||||||
|
<DeleteShipmentDialog
|
||||||
|
shipmentReference={shipment.reference}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteShipmentDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModeIcon({ mode }: { mode: ShipmentMode }) {
|
||||||
|
if (mode === "rail") return <Train className="h-4 w-4 text-[#33578D]" />;
|
||||||
|
if (mode === "truck") return <Truck className="h-4 w-4 text-[#33578D]" />;
|
||||||
|
return <Train className="h-4 w-4 text-[#33578D]" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ 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-3 py-1 text-xs font-medium ${styles[status]}`}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
151
apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts
Normal file
151
apps/edr-freight-web/portal/src/pages/tracking/shipments.mock.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { bookings } from "../bookings/bookings.mock";
|
||||||
|
|
||||||
|
export type ShipmentStatus = "In Transit" | "Delivered" | "Delayed";
|
||||||
|
export type ShipmentMode = "rail" | "truck" | "multimodal";
|
||||||
|
|
||||||
|
export interface Shipment {
|
||||||
|
id: number;
|
||||||
|
reference: string;
|
||||||
|
bookingId: number;
|
||||||
|
bookingReference: string;
|
||||||
|
customer: string;
|
||||||
|
originStation: string;
|
||||||
|
destinationStation: string;
|
||||||
|
mode: ShipmentMode;
|
||||||
|
status: ShipmentStatus;
|
||||||
|
currentLocation: string;
|
||||||
|
eta: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedShipments: Array<{
|
||||||
|
status: ShipmentStatus;
|
||||||
|
currentLocation: string;
|
||||||
|
eta: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
progress: number;
|
||||||
|
mode: ShipmentMode;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
status: "In Transit",
|
||||||
|
currentLocation: "Awash Junction",
|
||||||
|
eta: "2026-05-18",
|
||||||
|
lastUpdate: "2 hours ago",
|
||||||
|
progress: 45,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delivered",
|
||||||
|
currentLocation: "Dire Dawa Depot",
|
||||||
|
eta: "2026-05-12",
|
||||||
|
lastUpdate: "Yesterday",
|
||||||
|
progress: 100,
|
||||||
|
mode: "truck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "In Transit",
|
||||||
|
currentLocation: "Dire Dawa Yard",
|
||||||
|
eta: "2026-05-17",
|
||||||
|
lastUpdate: "30 minutes ago",
|
||||||
|
progress: 70,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delayed",
|
||||||
|
currentLocation: "Mieso",
|
||||||
|
eta: "2026-05-19",
|
||||||
|
lastUpdate: "5 hours ago",
|
||||||
|
progress: 35,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delivered",
|
||||||
|
currentLocation: "Adama Logistics Hub",
|
||||||
|
eta: "2026-05-10",
|
||||||
|
lastUpdate: "3 days ago",
|
||||||
|
progress: 100,
|
||||||
|
mode: "truck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "In Transit",
|
||||||
|
currentLocation: "Mieso Crossing",
|
||||||
|
eta: "2026-05-20",
|
||||||
|
lastUpdate: "1 hour ago",
|
||||||
|
progress: 55,
|
||||||
|
mode: "multimodal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delayed",
|
||||||
|
currentLocation: "Debre Markos",
|
||||||
|
eta: "2026-05-16",
|
||||||
|
lastUpdate: "6 hours ago",
|
||||||
|
progress: 60,
|
||||||
|
mode: "truck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "In Transit",
|
||||||
|
currentLocation: "Awash Junction",
|
||||||
|
eta: "2026-05-21",
|
||||||
|
lastUpdate: "45 minutes ago",
|
||||||
|
progress: 40,
|
||||||
|
mode: "multimodal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delivered",
|
||||||
|
currentLocation: "Djibouti Port",
|
||||||
|
eta: "2026-05-09",
|
||||||
|
lastUpdate: "5 days ago",
|
||||||
|
progress: 100,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "In Transit",
|
||||||
|
currentLocation: "Aysha Border",
|
||||||
|
eta: "2026-05-19",
|
||||||
|
lastUpdate: "20 minutes ago",
|
||||||
|
progress: 80,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delivered",
|
||||||
|
currentLocation: "Addis Ababa Warehouse",
|
||||||
|
eta: "2026-05-11",
|
||||||
|
lastUpdate: "4 days ago",
|
||||||
|
progress: 100,
|
||||||
|
mode: "truck",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: "Delayed",
|
||||||
|
currentLocation: "Dire Dawa Yard",
|
||||||
|
eta: "2026-05-22",
|
||||||
|
lastUpdate: "12 hours ago",
|
||||||
|
progress: 50,
|
||||||
|
mode: "rail",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const shipments: Shipment[] = seedShipments.map((entry, i) => {
|
||||||
|
const booking = bookings[i % bookings.length] as (typeof bookings)[number];
|
||||||
|
const id = i + 1;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
reference: `SH-${String(id).padStart(3, "0")}`,
|
||||||
|
bookingId: booking.id,
|
||||||
|
bookingReference: booking.reference,
|
||||||
|
customer: booking.customer,
|
||||||
|
originStation: booking.originStation,
|
||||||
|
destinationStation: booking.destinationStation,
|
||||||
|
mode: entry.mode,
|
||||||
|
status: entry.status,
|
||||||
|
currentLocation: entry.currentLocation,
|
||||||
|
eta: entry.eta,
|
||||||
|
lastUpdate: entry.lastUpdate,
|
||||||
|
progress: entry.progress,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getShipmentById(id: number | string): Shipment | undefined {
|
||||||
|
const numericId = typeof id === "string" ? Number(id) : id;
|
||||||
|
return shipments.find((s) => s.id === numericId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
apps/edr-freight-web/portal/src/pages/trains/NewTrainPage.tsx
Normal file
240
apps/edr-freight-web/portal/src/pages/trains/NewTrainPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
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-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/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-[#33578D] text-white hover:bg-[#33578D]/90">
|
||||||
|
{submitLabel}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,444 @@
|
|||||||
const TrainsPage = () => (
|
import { useMemo, useState } from "react";
|
||||||
<div className="flex flex-col gap-4">
|
import {
|
||||||
<h1 className="text-2xl font-semibold text-gray-900">Trains</h1>
|
ChevronLeft,
|
||||||
<p className="text-sm text-gray-600">
|
ChevronRight,
|
||||||
Fleet roster, capacity, and maintenance status. Connect to{" "}
|
Filter,
|
||||||
<code>/trains</code> when ready.
|
Gauge,
|
||||||
</p>
|
Pencil,
|
||||||
</div>
|
Plus,
|
||||||
);
|
Search,
|
||||||
|
Train as TrainIcon,
|
||||||
|
Trash2,
|
||||||
|
Wrench,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
export default TrainsPage;
|
import Breadcrumbs from "@/components/Breadcrumbs";
|
||||||
|
import NewTrainPage from "./NewTrainPage";
|
||||||
|
import DeleteTrainDialog from "./DeleteTrainDialog";
|
||||||
|
import { trains, type TrainStatus } from "./trains.mock";
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
|
||||||
|
|
||||||
|
type FilterValue = "All" | TrainStatus;
|
||||||
|
|
||||||
|
const FILTERS: FilterValue[] = [
|
||||||
|
"All",
|
||||||
|
"Operational",
|
||||||
|
"In Maintenance",
|
||||||
|
"Idle",
|
||||||
|
"Out of Service",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function TrainsPage() {
|
||||||
|
const [pageSize, setPageSize] = useState(10);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
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 totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const safePage = Math.min(page, totalPages);
|
||||||
|
const start = (safePage - 1) * pageSize;
|
||||||
|
const end = Math.min(start + pageSize, total);
|
||||||
|
const paginated = useMemo(
|
||||||
|
() => filtered.slice(start, end),
|
||||||
|
[filtered, start, end],
|
||||||
|
);
|
||||||
|
|
||||||
|
const operationalCount = trains.filter(
|
||||||
|
(t) => t.status === "Operational",
|
||||||
|
).length;
|
||||||
|
const maintenanceCount = trains.filter(
|
||||||
|
(t) => t.status === "In Maintenance",
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-50 p-6">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6">
|
||||||
|
<Breadcrumbs items={[{ label: "Trains" }]} />
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
Trains
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
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-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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Search trains..."
|
||||||
|
className="h-10 w-full rounded-2xl border border-slate-200 bg-white pl-10 pr-4 text-sm text-slate-700 outline-none transition placeholder:text-slate-400 focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NewTrainPage>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white transition hover:bg-[#33578D]/90"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Train
|
||||||
|
</button>
|
||||||
|
</NewTrainPage>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<StatCard
|
||||||
|
title="Total Trains"
|
||||||
|
value={String(trains.length)}
|
||||||
|
icon={<TrainIcon className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Operational"
|
||||||
|
value={String(operationalCount)}
|
||||||
|
icon={<Gauge className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="In Maintenance"
|
||||||
|
value={String(maintenanceCount)}
|
||||||
|
icon={<Wrench className="h-5 w-5" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="rounded-3xl bg-white p-2 shadow-sm">
|
||||||
|
<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);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "inline-flex items-center gap-2 rounded-2xl bg-[#33578D] px-4 py-2 text-sm font-medium text-white"
|
||||||
|
: "inline-flex items-center gap-2 rounded-2xl px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Train 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">
|
||||||
|
Fleet Roster
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
All locomotives and wagons in service.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="inline-flex items-center gap-2 rounded-2xl border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]">
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
|
Filter
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[900px] text-left">
|
||||||
|
<thead className="bg-slate-50 text-sm text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4 font-medium">Train</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Type</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Capacity</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Depot</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Mileage</th>
|
||||||
|
<th className="px-6 py-4 font-medium">Status</th>
|
||||||
|
<th className="px-6 py-4 font-medium text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{paginated.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={7}
|
||||||
|
className="px-6 py-12 text-center text-sm text-slate-500"
|
||||||
|
>
|
||||||
|
No trains match your filters.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginated.map((train) => (
|
||||||
|
<tr
|
||||||
|
key={train.id}
|
||||||
|
className="border-t border-slate-100 transition hover:bg-[#33578D]/5"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
<TrainIcon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{train.code}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{train.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{train.type}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{train.capacityTons}t
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{train.depot}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4 text-sm text-slate-700">
|
||||||
|
{train.mileageKm.toLocaleString()} km
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<StatusBadge status={train.status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<NewTrainPage
|
||||||
|
mode="edit"
|
||||||
|
train={{
|
||||||
|
code: train.code,
|
||||||
|
name: train.name,
|
||||||
|
type: train.type,
|
||||||
|
status: train.status,
|
||||||
|
capacityTons: train.capacityTons,
|
||||||
|
depot: train.depot,
|
||||||
|
manufacturer: train.manufacturer,
|
||||||
|
mileageKm: train.mileageKm,
|
||||||
|
lastMaintenance: train.lastMaintenance,
|
||||||
|
nextMaintenance: train.nextMaintenance,
|
||||||
|
currentAssignment: train.currentAssignment,
|
||||||
|
yearBuilt: train.yearBuilt,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-slate-200 p-2 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</NewTrainPage>
|
||||||
|
|
||||||
|
<DeleteTrainDialog trainCode={train.code}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-red-200 p-2 text-red-600 transition hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</DeleteTrainDialog>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={safePage}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
totalPages={totalPages}
|
||||||
|
start={start}
|
||||||
|
end={end}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
totalPages,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}) {
|
||||||
|
const showingFrom = total === 0 ? 0 : start + 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 border-t border-slate-100 px-6 py-4 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-slate-500">
|
||||||
|
<label htmlFor="train-page-size" className="font-medium text-slate-700">
|
||||||
|
Rows per page
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="train-page-size"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700 outline-none transition focus:border-[#33578D]/50 focus:ring-2 focus:ring-[#33578D]/20"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>
|
||||||
|
{size}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span>
|
||||||
|
Showing {showingFrom}–{end} of {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => {
|
||||||
|
const isActive = p === page;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={
|
||||||
|
isActive
|
||||||
|
? "h-9 w-9 rounded-xl bg-[#33578D] text-sm font-medium text-white"
|
||||||
|
: "h-9 w-9 rounded-xl border border-slate-200 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D]"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="inline-flex h-9 items-center gap-1 rounded-xl border border-slate-200 px-3 text-sm font-medium text-slate-700 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:bg-transparent disabled:hover:text-slate-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
value: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl bg-white p-6 shadow-sm transition hover:shadow-md hover:ring-1 hover:ring-[#33578D]/20">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-slate-500">{title}</p>
|
||||||
|
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-[#33578D]/10 text-[#33578D]">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
263
apps/edr-freight-web/portal/src/pages/trains/trains.mock.ts
Normal file
263
apps/edr-freight-web/portal/src/pages/trains/trains.mock.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Bell,
|
||||||
|
ChevronDown,
|
||||||
|
Languages,
|
||||||
|
LogOut,
|
||||||
|
Moon,
|
||||||
|
Sun,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import Sidebar, { SidebarItem } from "./Sidebar";
|
import Sidebar, { SidebarItem } from "./Sidebar";
|
||||||
|
|
||||||
export interface DashboardLayoutProps {
|
export interface DashboardLayoutProps {
|
||||||
@@ -7,32 +16,195 @@ export interface DashboardLayoutProps {
|
|||||||
activeHref?: string;
|
activeHref?: string;
|
||||||
onNavigate?: (href: string) => void;
|
onNavigate?: (href: string) => void;
|
||||||
headerRight?: ReactNode;
|
headerRight?: ReactNode;
|
||||||
|
enableThemeToggle?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Theme = "light" | "dark";
|
||||||
|
const THEME_STORAGE_KEY = "edr-theme";
|
||||||
|
|
||||||
|
function getInitialTheme(): Theme {
|
||||||
|
if (typeof window === "undefined") return "light";
|
||||||
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
if (stored === "dark" || stored === "light") return stored;
|
||||||
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
|
||||||
|
const iconButtonClass =
|
||||||
|
"inline-flex h-10 w-10 items-center justify-center rounded-xl border border-slate-200 text-slate-600 transition hover:border-[#33578D]/30 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:border-slate-700 dark:text-slate-300 dark:hover:border-[#33578D]/40 dark:hover:bg-[#33578D]/20 dark:hover:text-white";
|
||||||
|
|
||||||
const DashboardLayout = ({
|
const DashboardLayout = ({
|
||||||
title,
|
title,
|
||||||
sidebarItems,
|
sidebarItems,
|
||||||
activeHref,
|
activeHref,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
headerRight,
|
headerRight,
|
||||||
|
enableThemeToggle = false,
|
||||||
children,
|
children,
|
||||||
}: DashboardLayoutProps) => (
|
}: DashboardLayoutProps) => {
|
||||||
<div className="flex min-h-screen bg-gray-50">
|
const [theme, setTheme] = useState<Theme>(() =>
|
||||||
<Sidebar
|
enableThemeToggle ? getInitialTheme() : "light",
|
||||||
title={title}
|
);
|
||||||
items={sidebarItems}
|
|
||||||
activeHref={activeHref}
|
useEffect(() => {
|
||||||
onNavigate={onNavigate}
|
if (!enableThemeToggle) return;
|
||||||
/>
|
const root = document.documentElement;
|
||||||
<div className="flex flex-1 flex-col">
|
if (theme === "dark") {
|
||||||
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
root.classList.add("dark");
|
||||||
<div className="text-sm font-medium text-gray-700">{title}</div>
|
} else {
|
||||||
<div>{headerRight}</div>
|
root.classList.remove("dark");
|
||||||
</header>
|
}
|
||||||
<main className="flex-1 overflow-auto p-6">{children}</main>
|
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||||
|
}, [theme, enableThemeToggle]);
|
||||||
|
|
||||||
|
const toggleTheme = () =>
|
||||||
|
setTheme((current) => (current === "dark" ? "light" : "dark"));
|
||||||
|
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUserMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
userMenuRef.current &&
|
||||||
|
!userMenuRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape") setIsUserMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handlePointerDown);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("mousedown", handlePointerDown);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isUserMenuOpen]);
|
||||||
|
|
||||||
|
const themeToggleButton = enableThemeToggle ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={
|
||||||
|
theme === "dark" ? "Switch to light mode" : "Switch to dark mode"
|
||||||
|
}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-lg text-slate-600 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-slate-50 dark:bg-slate-950">
|
||||||
|
<Sidebar
|
||||||
|
title={title}
|
||||||
|
items={sidebarItems}
|
||||||
|
activeHref={activeHref}
|
||||||
|
onNavigate={onNavigate}
|
||||||
|
headerExtra={themeToggleButton}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6 dark:border-slate-800 dark:bg-slate-900">
|
||||||
|
<div className="text-base font-medium text-slate-700 dark:text-slate-200">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Change language"
|
||||||
|
className={iconButtonClass}
|
||||||
|
>
|
||||||
|
<Languages className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Notifications"
|
||||||
|
className={`${iconButtonClass} relative`}
|
||||||
|
>
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div ref={userMenuRef} className="relative ml-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isUserMenuOpen}
|
||||||
|
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#33578D]/20 hover:bg-[#33578D]/5 aria-expanded:border-[#33578D]/30 aria-expanded:bg-[#33578D]/10 dark:hover:border-[#33578D]/30 dark:hover:bg-[#33578D]/10 dark:aria-expanded:border-[#33578D]/40 dark:aria-expanded:bg-[#33578D]/20"
|
||||||
|
>
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#33578D] text-xs font-semibold text-white">
|
||||||
|
JD
|
||||||
|
</div>
|
||||||
|
<span className="hidden text-sm font-medium text-slate-700 md:block dark:text-slate-200">
|
||||||
|
John Doe
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-4 w-4 text-slate-400 transition dark:text-slate-500 ${
|
||||||
|
isUserMenuOpen ? "rotate-180 text-[#33578D]" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isUserMenuOpen ? (
|
||||||
|
<div
|
||||||
|
role="menu"
|
||||||
|
className="absolute right-0 top-full z-50 mt-2 w-52 overflow-hidden rounded-xl border border-slate-200 bg-white py-1 shadow-lg dark:border-slate-700 dark:bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="border-b border-slate-100 px-4 py-3 dark:border-slate-700">
|
||||||
|
<p className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
John Doe
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
john.doe@edr.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="#profile"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-slate-700 transition hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white"
|
||||||
|
>
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#logout"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 transition hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headerRight}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-auto bg-slate-50 p-6 dark:bg-slate-950">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
export default DashboardLayout;
|
export default DashboardLayout;
|
||||||
|
|||||||
@@ -12,39 +12,70 @@ export interface SidebarProps {
|
|||||||
items: SidebarItem[];
|
items: SidebarItem[];
|
||||||
activeHref?: string;
|
activeHref?: string;
|
||||||
onNavigate?: (href: string) => void;
|
onNavigate?: (href: string) => void;
|
||||||
|
headerExtra?: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Sidebar = ({ title, items, activeHref, onNavigate }: SidebarProps) => (
|
const Sidebar = ({
|
||||||
<aside className="flex w-60 flex-col gap-1 border-r border-gray-200 bg-white px-3 py-4">
|
title,
|
||||||
|
items,
|
||||||
|
activeHref,
|
||||||
|
onNavigate,
|
||||||
|
headerExtra,
|
||||||
|
}: SidebarProps) => (
|
||||||
|
<aside className="flex w-64 flex-col gap-1 border-r border-slate-200 bg-[#33578D]/10 px-3 py-5 dark:border-slate-800 dark:bg-slate-900">
|
||||||
{title ? (
|
{title ? (
|
||||||
<div className="px-2 pb-3 text-sm font-semibold text-gray-700">
|
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||||
{title}
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-[#33578D] text-sm font-bold text-white">
|
||||||
|
{title.charAt(0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<nav className="flex flex-col gap-0.5">
|
|
||||||
{items.map((item) => (
|
<nav className="flex flex-col gap-1">
|
||||||
<a
|
{items.map((item) => {
|
||||||
key={item.href}
|
const isActive =
|
||||||
href={item.href}
|
activeHref?.toLowerCase() === item.href.toLowerCase();
|
||||||
onClick={(event) => {
|
|
||||||
if (onNavigate) {
|
return (
|
||||||
event.preventDefault();
|
<a
|
||||||
onNavigate(item.href);
|
key={item.href}
|
||||||
}
|
href={item.href}
|
||||||
}}
|
onClick={(event) => {
|
||||||
className={clsx(
|
if (onNavigate) {
|
||||||
"flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors",
|
event.preventDefault();
|
||||||
activeHref === item.href
|
onNavigate(item.href);
|
||||||
? "bg-blue-50 text-blue-700"
|
}
|
||||||
: "text-gray-700 hover:bg-gray-100",
|
}}
|
||||||
)}
|
aria-current={isActive ? "page" : undefined}
|
||||||
>
|
className={clsx(
|
||||||
{item.icon ? (
|
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
|
||||||
<span className="text-gray-500">{item.icon}</span>
|
isActive
|
||||||
) : null}
|
? "bg-[#33578D] text-white shadow-sm shadow-[#33578D]/20"
|
||||||
<span>{item.label}</span>
|
: "text-slate-700 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white",
|
||||||
</a>
|
)}
|
||||||
))}
|
>
|
||||||
|
{item.icon ? (
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||||
|
isActive
|
||||||
|
? "text-white"
|
||||||
|
: "text-slate-500 group-hover:text-[#33578D] dark:text-slate-400 dark:group-hover:text-white",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
|
|||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -226,12 +226,18 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.7.7
|
specifier: ^1.7.7
|
||||||
version: 1.16.0
|
version: 1.16.0
|
||||||
|
class-variance-authority:
|
||||||
|
specifier: ^0.7.1
|
||||||
|
version: 0.7.1
|
||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.14.0
|
specifier: ^1.14.0
|
||||||
version: 1.14.0(react@18.3.1)
|
version: 1.14.0(react@18.3.1)
|
||||||
|
radix-ui:
|
||||||
|
specifier: ^1.4.3
|
||||||
|
version: 1.4.3(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
react:
|
react:
|
||||||
specifier: 18.3.1
|
specifier: 18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -241,6 +247,9 @@ importers:
|
|||||||
react-router-dom:
|
react-router-dom:
|
||||||
specifier: ^6.27.0
|
specifier: ^6.27.0
|
||||||
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||||
|
tailwind-merge:
|
||||||
|
specifier: ^3.6.0
|
||||||
|
version: 3.6.0
|
||||||
zustand:
|
zustand:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.0.13(@types/react@18.3.28)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
version: 5.0.13(@types/react@18.3.28)(immer@11.1.8)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1))
|
||||||
|
|||||||
Reference in New Issue
Block a user