Merge branch 'develop' into feat/add_auth_integration

This commit is contained in:
Michael Abebe
2026-05-18 11:14:55 +03:00
committed by GitHub
54 changed files with 10038 additions and 194 deletions

260
README.md
View File

@@ -1 +1,259 @@
# edr-platform
# EDR Platform
Monorepo for the **Ethio-Djibouti Railway** digital platform. Hosts two product lines — **Freight Management** and **Passenger Management** — each with a NestJS API plus React portal and back-office web apps, sharing TypeScript types, NestJS utilities, and a React component library.
---
## Tech Stack
### Backend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Runtime | Node.js ≥ 20 |
| Framework | NestJS 11 (modular architecture) |
| Language | TypeScript 5 (strict mode, project-wide) |
| ORM | TypeORM 0.3 (UUID PKs, soft deletes, `snake_case` columns) |
| Database | PostgreSQL 16 (one DB per domain) |
| Validation | class-validator + class-transformer |
| API docs | Swagger via `@nestjs/swagger` |
| Messaging | `@nestjs/microservices` (inter-service ready) |
| Testing | Jest + Supertest |
### Frontend
| Layer | Tech |
| ---------------- | ----------------------------------------------- |
| Framework | React 18 + Vite 5 |
| Language | TypeScript 5 (strict) |
| Routing | React Router v6 |
| Styling | Tailwind CSS v4 (`@tailwindcss/vite`) + `tailwind-merge` + `class-variance-authority` |
| UI primitives | Radix UI (meta `radix-ui` package, shadcn-style components) |
| Icons | lucide-react |
| State / Data | Zustand (client state) · TanStack Query (server state) |
| HTTP | Axios |
| Auth UI | `@tria-plc/iamui-common` (external IAM) |
### Shared Packages
| Package | Purpose |
| ---------------------- | -------------------------------------------------------------------- |
| `@edr/types` | Shared TypeScript interfaces and enums |
| `@edr/api-common` | NestJS decorators, filters, interceptors, pipes, `BaseEntity`, `BaseRepository` |
| `@edr/ui-common` | Shared React components (`DashboardLayout`, `Sidebar`, `Button`, `Modal`, etc.) and theme tokens |
| `@edr/eslint-config` | Shared ESLint configs (base / nestjs / react) |
| `@edr/tsconfig` | Shared TypeScript configs |
| `@edr/prettier-config` | Shared Prettier configuration |
### Tooling
- **pnpm 9** — workspace package manager (sole supported PM)
- **Turborepo 2** — task orchestrator with caching
- **Husky + lint-staged + commitlint** — pre-commit ESLint/Prettier and conventional-commit enforcement
- **Docker Compose** — local Postgres instances + production stack (see `infrastructure/`)
- **Nginx** — reverse proxy / static asset server in production
### Planned Integrations
- **MinIO** — S3-compatible object storage for documents (object keys already shaped under `edr-freight/{linkedType}/{ref}/{filename}`)
---
## Architecture
### High-level layout
```
┌──────────────────────────────────────────────────────────────────┐
│ EDR Platform (monorepo) │
├──────────────────────┬───────────────────────────────────────────┤
│ Freight domain │ Passenger domain │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-portal │ │ │ passenger- │ │
│ │ (React) │ │ │ portal (React)│ │
│ ├────────────────┤ │ ├────────────────┤ │
│ │ freight- │ │ │ passenger- │ │
│ │ backoffice │ │ │ backoffice │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ freight-api │ │ │ passenger-api │ │
│ │ (NestJS) │ │ │ (NestJS) │ │
│ └───────┬────────┘ │ └───────┬────────┘ │
│ │ │ │ │
│ ▼ │ ▼ │
│ ┌────────────────┐ │ ┌────────────────┐ │
│ │ postgres- │ │ │ postgres- │ │
│ │ freight │ │ │ passenger │ │
│ └────────────────┘ │ └────────────────┘ │
└──────────────────────┴───────────────────────────────────────────┘
Shared (workspace) packages
@edr/types · @edr/api-common · @edr/ui-common · @edr/{eslint,tsconfig,prettier}-config
```
### Domain isolation
- **One database per domain.** `postgres-freight` (port 5433, db `edr_freight`) and `postgres-passenger` (port 5434, db `edr_passenger`). No cross-database joins. Cross-domain data flows only through API calls or message queues.
- **Each domain owns its data model.** Freight bookings/consignments/shipments/trains/invoices/documents live only in the freight DB; passenger journeys/tickets live only in the passenger DB.
### NestJS module pattern (per feature)
```
modules/<feature>/
entities/<feature>.entity.ts // extends BaseEntity (UUID, timestamps, soft delete)
dto/<verb>-<feature>.dto.ts // class-validator DTOs
<feature>.module.ts // wires controller + service + repository
<feature>.controller.ts // HTTP layer only — no business logic
<feature>.service.ts // business logic
<feature>.repository.ts // extends BaseRepository<Entity>; services inject this, NEVER `Repository<T>` directly
```
Conventions enforced across the codebase:
- All entities have UUID primary keys (`@PrimaryGeneratedColumn('uuid')`).
- All entities inherit `createdAt` / `updatedAt` / `deletedAt` from `BaseEntity` (soft delete).
- DB columns use `snake_case` via `@Column({ name: '...' })`; TS properties stay `camelCase`.
- **No `synchronize: true`** in production — schema changes go through TypeORM migrations.
- ESLint + Prettier run on pre-commit via Husky + lint-staged.
- Conventional-commits enforced via commitlint.
### Frontend application structure
```
apps/edr-freight-web/portal/src/
App.tsx // routes + sidebar definition
main.tsx // React Router + QueryClient providers
components/
Breadcrumbs.tsx // shared local UI
ui/ // shadcn-style primitives (Button, Input, Dialog, Label, Textarea)
pages/
customers/ // CustomersPage + CustomerDetailPage + NewCustomerPage (dialog)
bookings/ // ... + multimodal Transport Legs editor
consignments/
tracking/ // Shipment grid + table view toggle
trains/ // Fleet roster
billing/ // Invoices
documents/ // MinIO-shaped document library
hooks/ // TanStack Query hooks (per feature)
services/ // Axios clients (per feature)
store/ // Zustand stores
lib/ // utilities (`cn` helper, formatters)
```
Each feature folder typically contains: `*Page.tsx` (list), `*DetailPage.tsx`, `New*Page.tsx` (create/edit dialog with `mode: "create" | "edit"`), `Delete*Dialog.tsx`, and a `*.mock.ts` seed file used by the current mock UI.
### Shared layout (`@edr/ui-common`)
`DashboardLayout` provides the sidebar + header shell shared across all freight and passenger web apps:
- **Sidebar** — brand-tinted, icon-led navigation; the brand color (`#33578D`) marks the active item.
- **Header** — language picker, notifications, user dropdown (click-driven, click-outside / Escape close); host apps opt into a light/dark theme toggle via `enableThemeToggle` (Tailwind class-based, persisted in `localStorage`).
### Auth integration (planned)
Authentication is provided by an external `@edr/iamui-common` / `@tria-plc/iamapi-common` package. **Do not** implement login/JWT/password logic in this repo. Use placeholder TODO comments next to controllers and `@CurrentUser` decorators (in `@edr/api-common`) until the integration ships.
---
## Apps & Ports
| App | Package name | Purpose | Port |
| ------------------------------ | --------------------------- | ---------------------------------------- | ---- |
| `edr-freight-api` | `@edr/freight-api` | NestJS API for freight | 3001 |
| `edr-freight-web/portal` | `@edr/freight-portal` | React frontend for freight customers | 5173 |
| `edr-freight-web/backoffice` | `@edr/freight-backoffice` | React frontend for freight employees | 5183 |
| `edr-passenger-api` | `@edr/passenger-api` | NestJS API for passengers | 3002 |
| `edr-passenger-web/portal` | `@edr/passenger-portal` | React frontend for passenger customers | 5174 |
| `edr-passenger-web/backoffice` | `@edr/passenger-backoffice` | React frontend for passenger employees | 5184 |
`edr-freight-web` and `edr-passenger-web` are grouping folders, not workspace packages. Each holds independent `portal/` and `backoffice/` workspace packages declared in `pnpm-workspace.yaml`.
---
## Getting Started
### Prerequisites
- Node.js ≥ 20
- pnpm 9 (`corepack enable && corepack prepare pnpm@9.12.0 --activate`)
- Docker (for local Postgres)
### Install
```bash
pnpm install
```
### Start local databases
```bash
docker compose -f infrastructure/docker/docker-compose.db.dev.yml up -d
```
### Run apps
```bash
pnpm dev # every app
pnpm dev:freight # freight API + portal + backoffice
pnpm dev:passenger # passenger API + portal + backoffice
```
### Common scripts
| Script | Description |
| ------------------- | ------------------------------------ |
| `pnpm install` | Install workspace dependencies |
| `pnpm dev` | Run every app in watch mode |
| `pnpm build` | Build every package and app |
| `pnpm test` | Run all tests |
| `pnpm lint` | Lint everything |
| `pnpm type-check` | Type-check every package |
| `pnpm format` | Format with Prettier |
---
## Repository Layout
```
.
├── apps/
│ ├── edr-freight-api/ NestJS — freight backend
│ ├── edr-freight-web/
│ │ ├── portal/ React — freight customer portal
│ │ └── backoffice/ React — freight back-office
│ ├── edr-passenger-api/ NestJS — passenger backend
│ └── edr-passenger-web/
│ ├── portal/ React — passenger customer portal
│ └── backoffice/ React — passenger back-office
├── packages/
│ ├── api-common/ Shared NestJS utilities + BaseEntity/Repository
│ ├── types/ Shared TS types/enums
│ ├── ui-common/ Shared React components + theme
│ └── config/
│ ├── eslint/ @edr/eslint-config
│ ├── tsconfig/ @edr/tsconfig
│ └── prettier/ @edr/prettier-config
├── infrastructure/
│ ├── docker/ docker-compose files (db.dev / dev / prod)
│ └── nginx/ Production nginx config
├── CLAUDE.md Developer guide for AI-assisted work
├── turbo.json Turborepo task pipeline
├── pnpm-workspace.yaml Workspace manifest
└── README.md
```
---
## Standards (recap)
- **TypeScript strict mode** is enabled in every package.
- **pnpm only** — never run `npm install` or `yarn`.
- **Conventional commits** — enforced via commitlint on every commit.
- **NestJS 4-layer pattern** — `module → controller → service → repository`.
- **Repository injection** — services inject the custom `*Repository` class, not `Repository<T>`.
- **Controllers are thin** — no business logic; delegate to services.
- **Migrations only** — never enable TypeORM `synchronize` in production.
- **One DB per domain** — no cross-database joins.
See [`CLAUDE.md`](./CLAUDE.md) for the deeper developer guide used during AI-assisted contributions.

View File

@@ -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: 8px;
height: 10px;
}
*::-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);
}

View File

@@ -17,10 +17,15 @@
"@tanstack/react-query": "^5.59.0",
"@tria-plc/iamui-common": "1.1.1",
"axios": "^1.7.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"radix-ui": "^1.4.3",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.27.0",
"recharts": "^3.8.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.0"
},
"devDependencies": {

View File

@@ -6,10 +6,21 @@ import {
Navigate,
} from "react-router-dom";
import { DashboardLayout, type SidebarItem } from "@edr/ui-common";
import {
LayoutDashboard,
Users,
CalendarCheck,
Package,
MapPin,
Train,
Receipt,
FileText,
Settings,
} from "lucide-react";
import BookingsPage from "./pages/bookings/BookingsPage";
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 ConsignmentDetailPage from "./pages/consignments/ConsignmentDetailPage";
import TrackingPage from "./pages/tracking/TrackingPage";
@@ -17,14 +28,22 @@ import BillingPage from "./pages/billing/BillingPage";
import TrainsPage from "./pages/trains/TrainsPage";
import DashboardPage from "./pages/dashboard/DashboardPage";
import { IamLoginPage, LoadingScreen, useAuth } from "@tria-plc/iamui-common";
import CustomersPage from "./pages/customers/CustomersPage";
import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
import NewCustomerPage from "./pages/customers/NewCustomerPage";
import DocumentsPage from "./pages/documents/DocumentsPage";
import DropdownSettingsPage from "./pages/admin/DropdownSettingsPage";
const sidebarItems: SidebarItem[] = [
{ label: "Dashboard", href: "/" },
{ label: "Bookings", href: "/bookings" },
{ label: "Consignments", href: "/consignments" },
{ label: "Tracking", href: "/tracking" },
{ label: "Trains", href: "/trains" },
{ label: "Billing", href: "/billing" },
{ label: "Dashboard", href: "/", icon: <LayoutDashboard /> },
{ label: "Customers", href: "/customers", icon: <Users /> },
{ label: "Bookings", href: "/bookings", icon: <CalendarCheck /> },
{ label: "Consignments", href: "/consignments", icon: <Package /> },
{ label: "Tracking", href: "/tracking", icon: <MapPin /> },
{ label: "Trains", href: "/trains", icon: <Train /> },
{ label: "Billing", href: "/billing", icon: <Receipt /> },
{ label: "Documents", href: "/documents", icon: <FileText /> },
{ label: "Dropdown Settings", href: "/admin/dropdowns", icon: <Settings /> },
];
const App = () => {
@@ -55,17 +74,26 @@ const App = () => {
sidebarItems={sidebarItems}
activeHref={location.pathname}
onNavigate={navigate}
enableThemeToggle
>
<Routes>
<Route path="/" element={<DashboardPage />} />
<Route path="/bookings" element={<BookingsPage />} />
<Route path="/bookings/new" element={<CreateBookingPage />} />
<Route path="/customers" element={<CustomersPage />} />
<Route path="/customers/:id" element={<CustomerDetailPage />} />
<Route path="/new-customer" element={<NewCustomerPage />} />
<Route path="/bookings/new" element={<NewBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route path="/consignments" element={<ConsignmentsPage />} />
<Route path="/consignments/:id" element={<ConsignmentDetailPage />} />
<Route path="/tracking" element={<TrackingPage />} />
<Route path="/trains" element={<TrainsPage />} />
<Route path="/billing" element={<BillingPage />} />
<Route path="/documents" element={<DocumentsPage />} />
<Route
path="/admin/dropdowns"
element={<DropdownSettingsPage />}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</DashboardLayout>

View File

@@ -0,0 +1,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>
);
}

View 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 };

View 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,
};

View 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 };

View 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 };

View 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 };

View 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));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,473 @@
const BillingPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Billing</h1>
<p className="text-sm text-gray-600">
Invoice list and payment status will live here. Wire up
@tanstack/react-query to <code>/billing/invoices</code> when the feature
is built out.
</p>
</div>
);
import { useMemo, useState } from "react";
import {
AlertCircle,
ChevronLeft,
ChevronRight,
Clock,
DollarSign,
Download,
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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Invoice</th>
<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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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,
})}`;
}

View File

@@ -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 { 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)
return <div className="text-sm text-red-600">Booking not found.</div>;
if (!booking) {
return (
<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 (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">
Booking {booking.reference}
</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Status</dt>
<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>
<dd className="text-gray-900">
{new Date(booking.scheduledDate).toLocaleString()}
</dd>
<dt className="text-gray-500">Total amount</dt>
<dd className="text-gray-900">{booking.totalAmount.toFixed(2)}</dd>
</dl>
<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: booking.reference },
]}
/>
{/* 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]">
<Package className="h-8 w-8" />
</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>
);
};
}
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>
);
}

View File

@@ -1,28 +1,380 @@
import { useMemo, useState } from "react";
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 { useBookings } from "../../hooks/useBookings";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewBookingPage from "./NewBookingPage";
import DeleteBookingDialog from "./DeleteBookingDialog";
import { bookings, type BookingStatus } from "./bookings.mock";
const BookingsPage = () => {
const { data, isLoading } = useBookings();
const items = data?.items ?? [];
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
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 (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-gray-900">Bookings</h1>
<Link to="/bookings/new">
<Button>New booking</Button>
</Link>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Bookings" }]} />
{/* 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">
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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Reference</th>
<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>
{isLoading ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<BookingTable bookings={items} />
)}
</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;

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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);
}

View File

@@ -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 { 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 (!data)
return <div className="text-sm text-red-600">Consignment not found.</div>;
if (!consignment) {
return (
<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 (
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold text-gray-900">
Consignment {data.trackingNumber}
</h1>
<dl className="grid grid-cols-2 gap-2 text-sm">
<dt className="text-gray-500">Status</dt>
<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>
<dd className="text-gray-900">{data.originStation}</dd>
<dt className="text-gray-500">Destination</dt>
<dd className="text-gray-900">{data.destinationStation}</dd>
<dt className="text-gray-500">Weight</dt>
<dd className="text-gray-900">{data.weightKg.toFixed(2)} kg</dd>
</dl>
<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: consignment.trackingNumber },
]}
/>
{/* 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]">
<Package className="h-8 w-8" />
</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}`}
/>
</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>
);
};
}
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>
);
}

View File

@@ -1,20 +1,503 @@
import ConsignmentTable from "../../components/consignments/ConsignmentTable";
import { useConsignments } from "../../hooks/useConsignments";
import { useMemo, useState } from "react";
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 = () => {
const { data, isLoading } = useConsignments();
const items = data?.items ?? [];
import Breadcrumbs from "@/components/Breadcrumbs";
import NewConsignmentPage from "./NewConsignmentPage";
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 (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Consignments</h1>
{isLoading ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<ConsignmentTable consignments={items} />
)}
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Consignments" }]} />
{/* 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">
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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Tracking #</th>
<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>
);
};
}
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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 (28°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);
}

View File

@@ -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>
);
}

View File

@@ -0,0 +1,357 @@
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() {
const [pageSize, setPageSize] = useState(10);
const [page, setPage] = useState(1);
const total = customers.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(
() => customers.slice(start, end),
[start, end],
);
return (
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Customers" }]} />
{/* 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">
Customers
</h1>
<p className="mt-1 text-sm text-slate-500">
Manage and monitor your customer records.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-80">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
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>
<NewCustomerPage>
<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" />
Add Customer
</button>
</NewCustomerPage>
</div>
</div>
{/* Stats */}
<div className="grid gap-4 md:grid-cols-3">
<StatCard
title="Total Customers"
value="1,284"
icon={<Users className="h-5 w-5" />}
/>
<StatCard
title="Active Accounts"
value="964"
icon={<UserCheck className="h-5 w-5" />}
/>
<StatCard
title="Pending Requests"
value="48"
icon={<Clock3 className="h-5 w-5" />}
/>
</div>
{/* Customer 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">
Customer List
</h2>
<p className="text-sm text-slate-500">
Recent customer activities and records.
</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-[700px] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Customer</th>
<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">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((customer) => (
<tr
key={customer.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]">
<User className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">
{customer.name}
</p>
<p className="text-sm text-slate-500">
ID #{customer.id}
</p>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{customer.company}
</td>
<td className="px-6 py-4 text-sm text-slate-700">
{customer.email}
</td>
<td className="px-6 py-4">
<StatusBadge status={customer.status} />
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<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" />
</Link>
<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="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>
<DeleteCustomerDialog customerName={customer.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>
</DeleteCustomerDialog>
</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="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({
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: CustomerStatus }) {
const styles: Record<CustomerStatus, string> = {
Active: "bg-emerald-100 text-emerald-700",
Pending: "bg-amber-100 text-amber-700",
Inactive: "bg-red-100 text-red-700",
};
return (
<span
className={`inline-flex rounded-full px-3 py-1 text-xs font-medium ${styles[status]}`}
>
{status}
</span>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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);
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Document</th>
<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>
);
}

View File

@@ -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>
);
}

View File

@@ -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`;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -1,33 +1,462 @@
import { useState } from "react";
import { Button, FormField } from "@edr/ui-common";
import { useMemo, useState } from "react";
import {
ArrowRight,
Building2,
Clock,
LayoutGrid,
List,
MapPin,
Pencil,
Plus,
Search,
Train,
Trash2,
Truck,
} from "lucide-react";
import TrackingTimeline from "../../components/tracking/TrackingTimeline";
import { useTracking } from "../../hooks/useTracking";
import Breadcrumbs from "@/components/Breadcrumbs";
import NewShipmentPage from "./NewShipmentPage";
import DeleteShipmentDialog from "./DeleteShipmentDialog";
import {
shipments,
type Shipment,
type ShipmentMode,
type ShipmentStatus,
} from "./shipments.mock";
const TrackingPage = () => {
const [consignmentId, setConsignmentId] = useState("");
const [activeId, setActiveId] = useState("");
const { data, isFetching } = useTracking(activeId);
type FilterValue = "All" | ShipmentStatus;
type ViewMode = "grid" | "table";
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 (
<div className="flex max-w-2xl flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Tracking</h1>
<div className="flex items-end gap-3">
<FormField
label="Consignment ID"
value={consignmentId}
onChange={(event) => setConsignmentId(event.target.value)}
placeholder="UUID"
/>
<Button onClick={() => setActiveId(consignmentId)}>Look up</Button>
<div className="min-h-screen bg-slate-50 p-6">
<div className="mx-auto max-w-7xl space-y-6">
<Breadcrumbs items={[{ label: "Tracking" }]} />
{/* 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">
Shipment Tracking
</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>
{isFetching ? (
<div className="text-sm text-gray-500">Loading</div>
) : (
<TrackingTimeline events={data ?? []} />
)}
</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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Shipment</th>
<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>
);
}

View 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);
}

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -1,11 +1,444 @@
const TrainsPage = () => (
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-gray-900">Trains</h1>
<p className="text-sm text-gray-600">
Fleet roster, capacity, and maintenance status. Connect to{" "}
<code>/trains</code> when ready.
</p>
</div>
);
import { useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
Filter,
Gauge,
Pencil,
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] whitespace-nowrap text-left">
<thead className="bg-slate-50 text-sm text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Train</th>
<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>
);
}

View 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);
}

View File

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

View File

@@ -3,7 +3,11 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"useDefineForClassFields": true,
"skipLibCheck": true
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@@ -1,9 +1,19 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
host: "0.0.0.0",

View File

@@ -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";
export interface DashboardLayoutProps {
@@ -7,32 +16,195 @@ export interface DashboardLayoutProps {
activeHref?: string;
onNavigate?: (href: string) => void;
headerRight?: ReactNode;
enableThemeToggle?: boolean;
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 = ({
title,
sidebarItems,
activeHref,
onNavigate,
headerRight,
enableThemeToggle = false,
children,
}: DashboardLayoutProps) => (
<div className="flex min-h-screen bg-gray-50">
<Sidebar
title={title}
items={sidebarItems}
activeHref={activeHref}
onNavigate={onNavigate}
/>
<div className="flex flex-1 flex-col">
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
<div className="text-sm font-medium text-gray-700">{title}</div>
<div>{headerRight}</div>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
}: DashboardLayoutProps) => {
const [theme, setTheme] = useState<Theme>(() =>
enableThemeToggle ? getInitialTheme() : "light",
);
useEffect(() => {
if (!enableThemeToggle) return;
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
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>
);
);
};
export default DashboardLayout;

View File

@@ -12,39 +12,70 @@ export interface SidebarProps {
items: SidebarItem[];
activeHref?: string;
onNavigate?: (href: string) => void;
headerExtra?: ReactNode;
}
const Sidebar = ({ title, items, activeHref, onNavigate }: SidebarProps) => (
<aside className="flex w-60 flex-col gap-1 border-r border-gray-200 bg-white px-3 py-4">
const Sidebar = ({
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 ? (
<div className="px-2 pb-3 text-sm font-semibold text-gray-700">
{title}
<div className="flex items-center justify-between gap-2 px-3 pb-4">
<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>
) : null}
<nav className="flex flex-col gap-0.5">
{items.map((item) => (
<a
key={item.href}
href={item.href}
onClick={(event) => {
if (onNavigate) {
event.preventDefault();
onNavigate(item.href);
}
}}
className={clsx(
"flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors",
activeHref === item.href
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100",
)}
>
{item.icon ? (
<span className="text-gray-500">{item.icon}</span>
) : null}
<span>{item.label}</span>
</a>
))}
<nav className="flex flex-col gap-1">
{items.map((item) => {
const isActive =
activeHref?.toLowerCase() === item.href.toLowerCase();
return (
<a
key={item.href}
href={item.href}
onClick={(event) => {
if (onNavigate) {
event.preventDefault();
onNavigate(item.href);
}
}}
aria-current={isActive ? "page" : undefined}
className={clsx(
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition",
isActive
? "bg-[#33578D] text-white shadow-sm shadow-[#33578D]/20"
: "text-slate-700 hover:bg-[#33578D]/10 hover:text-[#33578D] dark:text-slate-300 dark:hover:bg-[#33578D]/20 dark:hover:text-white",
)}
>
{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>
</aside>
);

15
pnpm-lock.yaml generated
View File

@@ -238,9 +238,18 @@ importers:
axios:
specifier: ^1.7.7
version: 1.16.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
lucide-react:
specifier: ^1.14.0
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:
specifier: 18.3.1
version: 18.3.1
@@ -250,6 +259,12 @@ importers:
react-router-dom:
specifier: ^6.27.0
version: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react-is@19.2.6)(react@18.3.1)(redux@5.0.1)
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
zustand:
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))