Merge freight/develop

This commit is contained in:
hagiye
2026-06-02 17:11:51 +03:00
171 changed files with 15339 additions and 5797 deletions

View File

@@ -1,6 +1,6 @@
import { type FormEvent, useState } from "react";
import { parsePhoneNumberFromString } from "libphonenumber-js";
import { Eye, EyeOff, Mail, Smartphone, UserRound } from "lucide-react";
import { Eye, EyeOff, Mail, Smartphone, UserRound, ArrowUpRight, Globe, ChevronDown } from "lucide-react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "@/auth/useAuth";
@@ -49,6 +49,108 @@ const normalizeIdentifier = (mode: LoginMode, value: string) => {
return trimmed;
};
const LOGIN_IMAGE = "/assets/login.png";
const EDR_LOGO = "/assets/logo.svg";
const fieldClass =
"h-11 w-full rounded-lg border border-gray-200 bg-[#eef4f8] px-4 text-sm text-gray-900 placeholder:text-gray-400 outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/15";
const primaryButtonClass =
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-sm transition-colors hover:bg-primary/90 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60";
const LeftPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<svg
className="absolute -bottom-24 -left-24 h-[420px] w-[420px] text-white/[0.07]"
viewBox="0 0 400 400"
fill="none"
>
{[0, 1, 2, 3, 4, 5].map((ring) => (
<circle key={ring} cx="200" cy="200" r={60 + ring * 36} stroke="currentColor" strokeWidth="1" />
))}
</svg>
<div className="absolute right-0 top-0 h-40 w-40 rounded-full bg-white/[0.06] blur-2xl" />
</div>
);
const RightPanelDecor = () => (
<div className="pointer-events-none absolute inset-0 overflow-hidden" aria-hidden>
<div className="absolute -right-16 -top-20 h-56 w-56 rounded-full bg-primary/[0.06] blur-3xl" />
<div className="absolute -bottom-12 left-1/4 h-40 w-40 rounded-full bg-primary/[0.04] blur-2xl" />
<svg className="absolute inset-0 h-full w-full text-gray-200/40" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="login-grid" width="28" height="28" patternUnits="userSpaceOnUse">
<circle cx="1" cy="1" r="0.75" fill="currentColor" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#login-grid)" />
</svg>
</div>
);
const LeftPanel = () => (
<div className="relative flex h-36 shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] sm:h-44 md:h-52 lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]">
<img
src={LOGIN_IMAGE}
alt="Ethio Djibouti Railway"
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/55 to-[#1a5c34]/45" />
<LeftPanelDecor />
<div className="relative z-10 flex shrink-0 items-center justify-between px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<img src={EDR_LOGO} alt="EDR Freight" className="h-7 w-auto brightness-0 invert sm:h-9" />
<a
href="#"
className="flex items-center gap-1.5 rounded-full border border-white/70 bg-white/10 px-3 py-1.5 text-xs font-medium text-white backdrop-blur-sm transition-colors hover:bg-white/20 sm:px-4 sm:py-2 sm:text-sm"
>
Support
<ArrowUpRight className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
</div>
<div className="relative z-10 mt-auto hidden px-8 pb-8 lg:block">
<div className="max-w-md rounded-2xl border border-white/15 bg-black/30 p-5 backdrop-blur-md">
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 shrink-0 rounded-full bg-primary" />
<span className="text-sm font-semibold text-white">
Empower Your Freight Operations
</span>
</div>
<p className="text-sm leading-relaxed text-white/85">
Sign in to manage bookings, track cargo, and run logistics operations on the
Ethio Djibouti Railway freight platform.
</p>
</div>
</div>
</div>
);
const LanguageSelector = () => (
<div className="flex cursor-pointer items-center gap-1.5 rounded-full border border-gray-200/80 bg-white px-3 py-1.5 text-sm text-gray-600 shadow-sm">
<Globe className="h-4 w-4 text-gray-500" />
<span>Eng</span>
<ChevronDown className="h-4 w-4 text-gray-400" />
</div>
);
const FormFooter = () => (
<div className="relative z-10 flex shrink-0 flex-col items-center justify-between gap-3 border-t border-gray-100 px-4 py-4 text-xs text-gray-400 sm:flex-row sm:gap-4 sm:px-6 sm:py-4 lg:px-8 lg:pb-6">
<span className="shrink-0">© 2026 EDR Freight</span>
<div className="flex flex-wrap items-center justify-center gap-3 sm:justify-end sm:gap-6">
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Terms & Conditions
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Privacy Policy
</a>
<a href="#" className="font-semibold text-gray-700 transition-colors hover:text-primary">
Help & Support
</a>
</div>
</div>
);
const LoginPage = () => {
const navigate = useNavigate();
const { login, verifyMfa } = useAuth();
@@ -63,7 +165,6 @@ const LoginPage = () => {
const [error, setError] = useState<string | null>(null);
const currentMode = loginModes.find((item) => item.value === mode)!;
const ModeIcon = currentMode.icon;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -103,146 +204,199 @@ const LoginPage = () => {
}
};
return (
<div className="min-h-screen px-6 py-10">
<div className="mx-auto grid min-h-[calc(100vh-5rem)] max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr]">
<section className="flex items-center">
<div className="w-full rounded-[2rem] border border-border/60 bg-card p-8 shadow-[0_20px_60px_rgba(15,23,42,0.12)] md:p-10">
{!needsMfa ? (
<form className="space-y-6" onSubmit={handleSubmit}>
<div>
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
Sign in
</p>
<h2 className="mt-3 text-3xl font-semibold text-foreground">
EDR Backoffice
</h2>
<p className="mt-2 text-sm text-muted-foreground">
Use your email, phone number, or username to access the internal freight dashboard.
</p>
</div>
<div className="grid gap-4">
<label className="grid gap-2 text-sm font-medium text-foreground">
Sign in method
<select
value={mode}
onChange={(event) => setMode(event.target.value as LoginMode)}
className="h-12 rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
>
{loginModes.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="grid gap-2 text-sm font-medium text-foreground">
{currentMode.label}
<div className="relative">
<ModeIcon className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMode.placeholder}
className="h-12 w-full rounded-xl border border-input bg-background pl-11 pr-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
/>
</div>
</label>
<label className="grid gap-2 text-sm font-medium text-foreground">
Password
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Enter your password"
className="h-12 w-full rounded-xl border border-input bg-background px-4 pr-12 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</label>
</div>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="inline-flex h-12 w-full items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "Signing in..." : "Sign in"}
</button>
</form>
) : (
<form className="space-y-6" onSubmit={handleVerifyMfa}>
<div>
<p className="text-sm font-medium uppercase tracking-[0.2em] text-[#0f766e]">
Multi-factor verification
</p>
<h2 className="mt-3 text-3xl font-semibold text-foreground">
Confirm one-time code
</h2>
<p className="mt-2 text-sm text-muted-foreground">
We sent a verification code for {normalizedIdentifier}. Enter it below to complete sign in.
</p>
</div>
<label className="grid gap-2 text-sm font-medium text-foreground">
Verification code
<input
value={otp}
onChange={(event) => setOtp(event.target.value)}
placeholder="Enter the code"
className="h-12 w-full rounded-xl border border-input bg-background px-4 text-sm outline-none transition focus:border-[#0f766e] focus:ring-2 focus:ring-[#0f766e]/20"
/>
</label>
{error ? (
<div className="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<button
type="button"
className="inline-flex h-12 items-center justify-center rounded-xl border border-input bg-background px-4 text-sm font-medium text-foreground transition hover:bg-accent"
onClick={() => {
setNeedsMfa(false);
setOtp("");
setError(null);
}}
>
Back
</button>
<button
type="submit"
disabled={submitting}
className="inline-flex h-12 items-center justify-center rounded-xl bg-[#0f766e] px-4 text-sm font-semibold text-white transition hover:bg-[#115e59] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting ? "Verifying..." : "Verify code"}
</button>
</div>
</form>
)}
</div>
</section>
const loginForm = (
<form className="flex w-full flex-col" onSubmit={handleSubmit}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">Get Started</h1>
<p className="text-sm leading-relaxed text-gray-500">
Log in to access the freight backoffice & explore all logistics resources.
</p>
</div>
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">Sign in method</label>
<div className="relative">
<select
value={mode}
onChange={(event) => setMode(event.target.value as LoginMode)}
className={`${fieldClass} appearance-none pr-10`}
>
{loginModes.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
</div>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
{currentMode.label} <span className="text-red-500">*</span>
</label>
<input
value={identifier}
onChange={(event) => setIdentifier(event.target.value)}
placeholder={currentMode.placeholder}
className={fieldClass}
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Password <span className="text-red-500">*</span>
</label>
<div className="relative">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Enter your password"
className={`${fieldClass} pr-11`}
/>
<button
type="button"
onClick={() => setShowPassword((current) => !current)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2.5">
<input
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-primary focus:ring-primary/20 focus:ring-offset-0"
/>
<span className="text-sm leading-snug text-gray-600">
I agree to EDR Freight{" "}
<a href="#" className="font-semibold text-primary hover:underline">
Terms & Conditions
</a>
.
</span>
</label>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<button type="submit" disabled={submitting} className={primaryButtonClass}>
{submitting ? "Signing in..." : "Sign In"}
</button>
<p className="text-center text-sm text-gray-500">
Need an account?{" "}
<a href="#" className="font-semibold text-primary hover:underline">
Contact your admin
</a>
</p>
</div>
</form>
);
const mfaForm = (
<form className="flex w-full flex-col" onSubmit={handleVerifyMfa}>
<div className="mb-4 flex justify-center sm:mb-6">
<img src={EDR_LOGO} alt="EDR Freight" className="h-9 w-auto sm:h-11" />
</div>
<div className="mb-4 space-y-1.5 text-center sm:mb-5">
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
Multi-factor verification
</h1>
<p className="text-sm leading-relaxed text-gray-500">
We sent a verification code to{" "}
<span className="font-medium text-gray-700">{normalizedIdentifier}</span>. Enter it below
to complete sign in.
</p>
</div>
<div className="flex w-full flex-col gap-4">
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-800">
Verification code <span className="text-red-500">*</span>
</label>
<input
value={otp}
onChange={(event) => setOtp(event.target.value)}
placeholder="Enter the code"
className={fieldClass}
/>
</div>
{error ? (
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
{error}
</div>
) : null}
<div className="flex w-full gap-3">
<button
type="button"
onClick={() => {
setNeedsMfa(false);
setOtp("");
setError(null);
}}
className="h-11 min-w-0 flex-1 rounded-full border border-gray-200 bg-white text-sm font-semibold text-gray-700 transition-colors hover:border-gray-300 hover:bg-gray-50"
>
Back
</button>
<button type="submit" disabled={submitting} className={`${primaryButtonClass} min-w-0 flex-1`}>
{submitting ? "Verifying..." : "Verify"}
</button>
</div>
</div>
</form>
);
return (
<>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<div
className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]"
style={{ fontFamily: "'Outfit', var(--font-sans)" }}
>
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
<LeftPanel />
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-[#f5f7fa] shadow-[0_8px_32px_rgba(15,23,42,0.08)] lg:basis-1/2 lg:rounded-[28px]">
<RightPanelDecor />
<div className="relative z-10 flex shrink-0 justify-end px-4 pt-4 sm:px-6 sm:pt-6 lg:px-8 lg:pt-8">
<LanguageSelector />
</div>
<div className="relative z-10 min-h-0 flex-1 overflow-y-auto overscroll-contain">
<div className="flex min-h-full justify-center px-4 py-4 sm:px-6 sm:py-6 lg:px-8 lg:py-8">
<div className="my-auto w-full rounded-2xl border border-gray-100/80 bg-white px-5 py-6 shadow-[0_4px_24px_rgba(15,23,42,0.06)] sm:px-7 sm:py-8 lg:px-9 lg:py-9">
{!needsMfa ? loginForm : mfaForm}
</div>
</div>
</div>
<FormFooter />
</div>
</div>
</div>
</>
);
};

View File

@@ -0,0 +1,734 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
AlertTriangle,
Anchor,
ArrowLeft,
ArrowRight,
Calendar,
Check,
CheckCircle2,
Clock,
FileSignature,
FileText,
History,
Info,
MapPin,
Package,
ShieldCheck,
Ship,
StickyNote,
Train,
Truck,
Weight,
X,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { cn } from "@/lib/utils";
import {
getBookingRequestById,
getBookingRequests,
saveBookingRequestsToStorage,
updateBookingRequestStatus,
BOOKING_STATUSES,
type BookingRequest,
} from "./booking-requests.mock";
import {
Badge,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Separator,
} from "@edr/ui-common";
const STATUS_STYLES: Record<string, { label: string; color: string }> = {
DRAFT: {
label: "Draft",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
RFQ_SUBMITTED: {
label: "RFQ Submitted",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
QUOTATION_SENT: {
label: "Quotation Sent",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
QUOTATION_APPROVED: {
label: "Quotation Approved",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
QUOTATION_REJECTED: {
label: "Quotation Rejected",
color: "bg-red-50 text-red-700 border-red-200",
},
PENDING_APPROVAL: {
label: "Pending Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
APPROVED: {
label: "Approved",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
SIGNED_CUSTOMER: {
label: "Customer Signed",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
FULLY_EXECUTED: {
label: "Fully Executed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
PAID: {
label: "Paid",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
IN_TRANSIT: {
label: "In Transit",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
COMPLETED: {
label: "Completed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
CANCELLED: {
label: "Cancelled",
color: "bg-red-50 text-red-700 border-red-200",
},
PENDING_CONSOLIDATION: {
label: "Pending Consolidation",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
CONSOLIDATED: {
label: "Consolidated",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
};
const PROGRESS_STAGES = [
{ label: "Request", icon: FileText, statuses: ["DRAFT", "RFQ_SUBMITTED"] },
{
label: "Quotation",
icon: ShieldCheck,
statuses: ["QUOTATION_SENT", "QUOTATION_APPROVED", "QUOTATION_REJECTED"],
},
{
label: "Approval",
icon: FileSignature,
statuses: ["PENDING_APPROVAL", "APPROVED"],
},
{
label: "Execution",
icon: CheckCircle2,
statuses: ["SIGNED_CUSTOMER", "FULLY_EXECUTED", "PAID"],
},
{
label: "In Transit",
icon: Train,
statuses: ["IN_TRANSIT", "PENDING_CONSOLIDATION", "CONSOLIDATED"],
},
{ label: "Complete", icon: Check, statuses: ["COMPLETED"] },
];
const STATUS_CONFIG: Record<
string,
{ title: string; description: string; color: string; stage: number }
> = {
DRAFT: {
title: "Draft",
description: "Booking is being prepared.",
color: "text-slate-500",
stage: 0,
},
RFQ_SUBMITTED: {
title: "RFQ Submitted",
description: "Customer has submitted a request for quotation.",
color: "text-amber-600",
stage: 0,
},
QUOTATION_SENT: {
title: "Quotation Sent",
description: "A formal quotation has been sent to the customer.",
color: "text-sky-600",
stage: 1,
},
QUOTATION_APPROVED: {
title: "Quotation Approved",
description: "Customer approved the quotation.",
color: "text-emerald-600",
stage: 1,
},
QUOTATION_REJECTED: {
title: "Quotation Rejected",
description: "Customer rejected the quotation.",
color: "text-red-600",
stage: 1,
},
PENDING_APPROVAL: {
title: "Pending Approval",
description: "Booking requires your approval to proceed.",
color: "text-amber-600",
stage: 2,
},
APPROVED: {
title: "Approved",
description: "Booking has been approved by all parties.",
color: "text-emerald-600",
stage: 2,
},
SIGNED_CUSTOMER: {
title: "Customer Signed",
description: "Customer has signed the contract.",
color: "text-sky-600",
stage: 3,
},
FULLY_EXECUTED: {
title: "Fully Executed",
description: "All parties have signed.",
color: "text-indigo-600",
stage: 3,
},
PAID: {
title: "Paid",
description: "Payment received.",
color: "text-emerald-600",
stage: 3,
},
IN_TRANSIT: {
title: "In Transit",
description: "Cargo is moving through the rail network.",
color: "text-sky-600",
stage: 4,
},
PENDING_CONSOLIDATION: {
title: "Pending Consolidation",
description: "Cargo awaiting consolidation.",
color: "text-amber-500",
stage: 4,
},
CONSOLIDATED: {
title: "Consolidated",
description: "Cargo merged into larger shipment.",
color: "text-indigo-500",
stage: 4,
},
COMPLETED: {
title: "Completed",
description: "Service completed successfully.",
color: "text-emerald-600",
stage: 5,
},
CANCELLED: {
title: "Cancelled",
description: "Booking terminated.",
color: "text-red-600",
stage: -1,
},
};
export default function BookingRequestDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [booking, setBooking] = useState<BookingRequest | undefined>(
id ? getBookingRequestById(id) : undefined,
);
if (!booking) {
return (
<div className="p-6">
<Card className="flex flex-col items-center p-12 text-center">
<div className="flex size-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<Package className="size-8" />
</div>
<h1 className="mt-4 text-2xl font-bold text-slate-900">
Booking not found
</h1>
<Button
className="mt-4"
variant="outline"
onClick={() => navigate("/dashboard/booking-requests")}
>
<ArrowLeft />
Back to Booking Requests
</Button>
</Card>
</div>
);
}
const statusConfig = STATUS_CONFIG[booking.status] ?? STATUS_CONFIG.DRAFT;
const currentStage = statusConfig.stage;
const canApprove = ["PENDING_APPROVAL", "RFQ_SUBMITTED"].includes(
booking.status,
);
const canReject = !["COMPLETED", "CANCELLED", "QUOTATION_REJECTED"].includes(
booking.status,
);
function handleApprove() {
if (!booking) return;
const nextStatus =
booking.status === "RFQ_SUBMITTED"
? ("QUOTATION_SENT" as const)
: ("APPROVED" as const);
updateBookingRequestStatus(booking.id, nextStatus);
setBooking(getBookingRequestById(booking.id));
}
function handleReject() {
if (!booking) return;
updateBookingRequestStatus(booking.id, "CANCELLED");
setBooking(getBookingRequestById(booking.id));
}
return (
<div className="p-6">
<div className="space-y-6">
<Breadcrumbs
items={[
{ label: "Booking Requests", href: "/dashboard/booking-requests" },
{ label: booking.reference },
]}
/>
<div className="flex items-start justify-between gap-4">
<Card className="flex-1">
<CardHeader>
<div className="flex items-center gap-6">
<div className="flex size-14 items-center justify-center rounded-2xl bg-primary text-primary-foreground">
<Package className="size-6" />
</div>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-black tracking-tight text-foreground">
{booking.reference}
</h1>
<StatusBadge status={booking.status} />
<PriorityBadge score={booking.priorityScore} />
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-semibold">{booking.customer}</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Calendar className="size-3" />
Requested {booking.scheduledDate}
</span>
<Separator orientation="vertical" className="h-3" />
<span className="flex items-center gap-1">
<Clock className="size-3" />
{new Date(booking.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</CardHeader>
</Card>
<div className="flex shrink-0 items-start gap-2">
{canReject && (
<Button
variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={handleReject}
>
<X />
Reject
</Button>
)}
{canApprove && (
<Button onClick={handleApprove}>
<ShieldCheck />
{booking.status === "RFQ_SUBMITTED"
? "Send Quotation"
: "Approve"}
</Button>
)}
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<History className="size-4 text-primary" />
Status Lifecycle
</CardTitle>
<CardDescription>
Track the booking from request to completion
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-8">
<div className="relative flex w-full justify-between px-2">
<div className="absolute left-0 top-4 h-0.5 w-full bg-muted">
<div
className="h-full bg-primary transition-all duration-500"
style={{
width:
currentStage >= 0
? `${(currentStage / (PROGRESS_STAGES.length - 1)) * 100}%`
: "0%",
}}
/>
</div>
{PROGRESS_STAGES.map((stage, idx) => {
const isCompleted = idx < currentStage;
const isActive = idx === currentStage;
return (
<div
key={stage.label}
className="relative z-10 flex flex-col items-center gap-2"
>
<div
className={cn(
"flex size-8 items-center justify-center rounded-full border-2 bg-background transition-all duration-300",
isCompleted
? "border-primary text-primary"
: isActive
? "scale-110 border-primary text-primary shadow-[0_0_10px_rgba(16,185,129,0.3)]"
: "border-muted text-muted-foreground",
)}
>
{isCompleted ? (
<CheckCircle2 className="size-4" />
) : (
<stage.icon className="size-4" />
)}
</div>
<span
className={cn(
"text-[9px] font-bold uppercase tracking-widest",
isActive ? "text-primary" : "text-muted-foreground",
)}
>
{stage.label}
</span>
</div>
);
})}
</div>
<div className="flex flex-col gap-4 rounded-xl border border-border/50 bg-muted/20 p-5 md:flex-row md:items-center">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full bg-background shadow-sm">
{booking.status === "CANCELLED" ? (
<AlertTriangle className="size-5 text-red-500" />
) : (
<Info className="size-5 text-primary" />
)}
</div>
<div className="flex flex-col gap-0.5">
<h4
className={cn(
"text-sm font-black uppercase tracking-tight",
statusConfig.color,
)}
>
{statusConfig.title}
</h4>
<p className="text-xs font-medium text-muted-foreground">
{statusConfig.description}
</p>
</div>
</div>
</CardContent>
</Card>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
<div className="flex flex-col gap-8 lg:col-span-2">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Anchor className="size-4 text-primary" />
Route & Service
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="flex flex-col items-center justify-between gap-4 rounded-xl border bg-muted/30 p-4 md:flex-row">
<RouteEndpoint
label="Origin Yard"
station={booking.originYard}
icon={<MapPin />}
/>
<div className="flex flex-col items-center gap-1 text-primary">
<div className="flex items-center gap-2">
<Train className="size-5" />
<ArrowRight className="size-4" />
</div>
<Badge
variant="outline"
className="border-primary/20 bg-primary/5 text-[9px] font-bold uppercase"
>
{booking.serviceType.replace(/_/g, " ")}
</Badge>
</div>
<RouteEndpoint
label="Destination Yard"
station={booking.destinationYard}
icon={<MapPin />}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem
icon={<Ship />}
label="Trade Direction"
value={booking.tradeDirection}
/>
<InfoItem
icon={<ShieldCheck />}
label="Return"
value={
booking.serviceType === "RAIL_AND_FORWARDING"
? "With Return"
: "Without Return"
}
/>
{booking.shippingLine && (
<InfoItem
icon={<Ship />}
label="Shipping Line"
value={booking.shippingLine}
/>
)}
</div>
</CardContent>
</Card>
{(booking.firstMilePickupAddress ||
booking.lastMileDeliveryAddress) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Truck className="size-4 text-primary" />
Mile Services
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-1 gap-6 md:grid-cols-2">
{booking.firstMilePickupAddress && (
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
First Mile
</h3>
<InfoItem
label="Pickup"
value={booking.firstMilePickupAddress}
/>
</div>
)}
{booking.lastMileDeliveryAddress && (
<div className="flex flex-col gap-3">
<h3 className="border-l-4 border-primary pl-3 text-xs font-bold uppercase tracking-wide text-foreground">
Last Mile
</h3>
<InfoItem
label="Delivery"
value={booking.lastMileDeliveryAddress}
/>
</div>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Package className="size-4 text-primary" />
Cargo Specifications
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<InfoItem
icon={<Package />}
label="Type"
value={booking.cargoType}
/>
<InfoItem
icon={<Weight />}
label="Total Weight"
value={`${booking.cargoTotalWeightVgm} Tons`}
/>
{booking.shippingLine && (
<InfoItem
icon={<Ship />}
label="Shipping Line"
value={booking.shippingLine}
/>
)}
</div>
<Separator />
<div className="flex flex-wrap gap-2">
<Badge variant="outline" className="bg-background text-[9px]">
Hazardous: {booking.isHazardous ? "Yes" : "No"}
</Badge>
{booking.pnrCode && (
<Badge
variant="outline"
className="bg-background text-[9px]"
>
PNR: {booking.pnrCode}
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
<div className="flex flex-col gap-8">
<Card className="border-primary/20 bg-primary/[0.02]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="size-4 text-primary" />
Contract Info
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<InfoItem label="Type" value={booking.contractType} />
<InfoItem label="Currency" value={booking.paymentCurrency} />
<InfoItem
label="Amount"
value={`${booking.paymentCurrency} ${booking.totalAmount.toLocaleString()}`}
/>
<InfoItem label="Payment" value={booking.paymentStatus} />
<Separator />
<InfoItem label="Created By" value={booking.createdBy} />
<InfoItem
label="Created"
value={new Date(booking.createdAt).toLocaleDateString()}
/>
<InfoItem
label="Last Updated"
value={new Date(booking.updatedAt).toLocaleDateString()}
/>
</CardContent>
</Card>
{canApprove && (
<Card className="border-amber-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base text-amber-800">
<AlertCircle className="size-4" />
Approval Required
</CardTitle>
<CardDescription className="text-amber-700">
This booking is waiting for your review.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<Button onClick={handleApprove}>
<ShieldCheck />
{booking.status === "RFQ_SUBMITTED"
? "Send Quotation"
: "Approve Booking"}
</Button>
<Button
variant="outline"
className="border-red-200 text-red-700 hover:bg-red-50"
onClick={handleReject}
>
<X />
Reject
</Button>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const style = STATUS_STYLES[status] ?? {
label: status,
color: "bg-muted text-muted-foreground border-border",
};
return (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
style.color,
)}
>
{style.label}
</Badge>
);
}
function PriorityBadge({ score }: { score: number }) {
if (score >= 3) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score === 2) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}
function RouteEndpoint({
label,
station,
icon,
}: {
label: string;
station: string;
icon: React.ReactNode;
}) {
return (
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-sm">
<div className="[&_svg]:size-5">{icon}</div>
</div>
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-wide text-muted-foreground">
{label}
</p>
<p className="text-sm font-black text-foreground">{station}</p>
</div>
</div>
);
}
function InfoItem({
icon,
label,
value,
}: {
icon?: React.ReactNode;
label: string;
value?: string | number | null;
}) {
return (
<div className="flex items-start gap-2">
{icon && (
<div className="mt-0.5 text-muted-foreground [&_svg]:size-3.5">
{icon}
</div>
)}
<div className="flex flex-col">
<p className="text-[9px] font-bold uppercase tracking-tight text-muted-foreground">
{label}
</p>
<p className="text-xs font-bold text-foreground">{value ?? "—"}</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,479 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AlertCircle,
ArrowRight,
Calendar,
Clock,
Eye,
FileText,
Filter,
MoreHorizontal,
Package,
Search,
ShieldCheck,
Train,
User,
} from "lucide-react";
import Breadcrumbs from "@/components/ui/Breadcrumbs";
import { cn } from "@/lib/utils";
import {
getBookingRequests,
BOOKING_STATUSES,
type BookingRequest,
} from "./booking-requests.mock";
import {
DataTable,
DataTableFooter,
type ColumnDef,
usePagination,
Badge,
Button,
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Input,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
Separator,
} from "@edr/ui-common";
const STATUS_STYLES: Record<string, { label: string; color: string }> = {
DRAFT: {
label: "Draft",
color: "bg-slate-100 text-slate-700 border-slate-300",
},
RFQ_SUBMITTED: {
label: "RFQ Submitted",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
QUOTATION_SENT: {
label: "Quotation Sent",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
QUOTATION_APPROVED: {
label: "Quotation Approved",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
QUOTATION_REJECTED: {
label: "Quotation Rejected",
color: "bg-red-50 text-red-700 border-red-200",
},
PENDING_APPROVAL: {
label: "Pending Approval",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
APPROVED: {
label: "Approved",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
SIGNED_CUSTOMER: {
label: "Customer Signed",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
FULLY_EXECUTED: {
label: "Fully Executed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
PAID: {
label: "Paid",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
IN_TRANSIT: {
label: "In Transit",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
COMPLETED: {
label: "Completed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
CANCELLED: {
label: "Cancelled",
color: "bg-red-50 text-red-700 border-red-200",
},
PENDING_CONSOLIDATION: {
label: "Pending Consolidation",
color: "bg-amber-50 text-amber-700 border-amber-200",
},
CONSOLIDATED: {
label: "Consolidated",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
},
};
function StatusBadge({ status }: { status: string }) {
const style = STATUS_STYLES[status] ?? {
label: status,
color: "bg-muted text-muted-foreground border-border",
};
return (
<Badge
variant="outline"
className={cn(
"px-2 py-0.5 font-bold uppercase tracking-wider text-[9px]",
style.color,
)}
>
{style.label}
</Badge>
);
}
function PriorityBadge({ score }: { score: number }) {
if (score >= 3) {
return (
<span className="rounded-full bg-red-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-red-700">
Urgent
</span>
);
}
if (score === 2) {
return (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700">
High
</span>
);
}
return (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-slate-600">
Normal
</span>
);
}
export default function BookingRequestsPage() {
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const bookingRequests = useMemo(() => getBookingRequests(), []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return bookingRequests.filter((b) => {
if (
q &&
!b.reference.toLowerCase().includes(q) &&
!b.customer.toLowerCase().includes(q)
) {
return false;
}
if (statusFilter && b.status !== statusFilter) {
return false;
}
return true;
});
}, [bookingRequests, query, statusFilter]);
const total = filtered.length;
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
const start = pagination.pageIndex * pagination.pageSize;
const end = Math.min(start + pagination.pageSize, total);
const paginatedData = useMemo(
() => filtered.slice(start, end),
[start, end, filtered],
);
const pendingCount = bookingRequests.filter(
(b) => b.status === "PENDING_APPROVAL" || b.status === "RFQ_SUBMITTED",
).length;
const activeCount = bookingRequests.filter(
(b) => !["COMPLETED", "CANCELLED"].includes(b.status),
).length;
const urgentCount = bookingRequests.filter(
(b) => b.priorityScore >= 3,
).length;
const columns: ColumnDef<BookingRequest>[] = [
{
id: "booking",
header: "Booking",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-primary-foreground">
<Package className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-slate-900">{b.reference}</p>
<p className="flex items-center gap-1 text-xs text-slate-500">
<User className="h-3 w-3" />
{b.customer}
</p>
</div>
</div>
);
},
},
{
id: "route",
header: "Route",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1 text-xs font-medium text-slate-700">
<span>{b.originYard}</span>
<ArrowRight className="h-3 w-3 text-slate-400" />
<span>{b.destinationYard}</span>
</div>
<span className="text-[10px] uppercase tracking-wide text-slate-500">
{b.tradeDirection}
</span>
</div>
);
},
},
{
id: "status",
header: "Status",
cell: ({ row }) => <StatusBadge status={row.original.status} />,
},
{
id: "service",
header: "Service",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.serviceType.replace(/_/g, " ")}
</span>
<span className="flex items-center gap-1 text-[10px] text-slate-500">
<Calendar className="h-3 w-3" />
{b.scheduledDate}
</span>
</div>
);
},
},
{
id: "cargo",
header: "Cargo",
cell: ({ row }) => {
const b = row.original;
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs font-medium text-slate-700">
{b.cargoType}
</span>
<span className="text-[10px] text-slate-500">
{b.cargoTotalWeightVgm}T
</span>
</div>
);
},
},
{
id: "priority",
header: "Priority",
cell: ({ row }) => <PriorityBadge score={row.original.priorityScore} />,
},
{
id: "amount",
header: "Amount",
cell: ({ row }) => {
const b = row.original;
return (
<span className="font-mono text-xs font-semibold text-slate-900">
{b.paymentCurrency} {b.totalAmount.toLocaleString()}
</span>
);
},
},
{
id: "actions",
size: 40,
cell: ({ row }) => {
const b = row.original;
return (
<div
className="flex justify-end"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<Eye />
View Details
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() =>
navigate(`/dashboard/booking-requests/${b.id}`)
}
>
<AlertCircle />
Review
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
},
},
];
return (
<div className="min-h-screen p-6">
<div className="space-y-6">
<Breadcrumbs items={[{ label: "Booking Requests" }]} />
<Card className="flex-row justify-between p-6">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">
Booking Requests
</h1>
<p className="mt-1 text-sm text-secondary-foreground">
Review, approve, or reject customer booking requests across the
freight network.
</p>
</div>
<div className="flex flex-col items-stretch gap-3 sm:flex-row sm:items-center">
<div className="relative w-full sm:w-72">
<Search className="pointer-events-none absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
type="search"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPagination({
pageIndex: 0,
pageSize: pagination.pageSize,
});
}}
placeholder="Search reference or customer..."
className="pl-8!"
/>
</div>
</div>
</Card>
<div className="grid gap-4 md:grid-cols-4">
<StatCard
label="Total Requests"
value={bookingRequests.length}
icon={<FileText />}
/>
<StatCard
label="Pending Action"
value={pendingCount}
icon={<Clock />}
/>
<StatCard label="Active" value={activeCount} icon={<Train />} />
<StatCard label="Urgent" value={urgentCount} icon={<AlertCircle />} />
</div>
<Card className="gap-0">
<CardHeader className="flex flex-row items-center justify-between border-b">
<div>
<CardTitle>All Booking Requests</CardTitle>
<CardDescription>
{total} request{total !== 1 ? "s" : ""} found
</CardDescription>
</div>
<div className="flex items-center gap-2">
{statusFilter && (
<Button
variant="ghost"
size="sm"
onClick={() => setStatusFilter(null)}
>
Clear filter
</Button>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="secondary" size="sm">
<Filter />
{statusFilter
? (STATUS_STYLES[statusFilter]?.label ?? "Filter")
: "Filter"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{BOOKING_STATUSES.map((s) => (
<DropdownMenuItem
key={s}
onSelect={() => setStatusFilter(s)}
>
{STATUS_STYLES[s]?.label ?? s}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="px-0">
<DataTable
columns={columns}
data={paginatedData}
status="success"
onRowClick={(row) =>
navigate(`/dashboard/booking-requests/${row.id}`)
}
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: total,
}}
tableOptions={{
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-b shadow-none"
footer={DataTableFooter}
/>
</CardContent>
</Card>
</div>
</div>
);
}
function StatCard({
label,
value,
icon,
}: {
label: string;
value: number;
icon: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{label}</p>
<h3 className="mt-2 text-3xl font-bold text-slate-900">{value}</h3>
</div>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
{icon}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,148 @@
export interface BookingRequest {
id: string;
reference: string;
customer: string;
status: (typeof BOOKING_STATUSES)[number];
scheduledDate: string;
totalAmount: number;
paymentStatus: string;
contractType: string;
serviceType: string;
tradeDirection: string;
originYard: string;
destinationYard: string;
cargoType: string;
cargoTotalWeightVgm: number;
isHazardous: boolean;
paymentCurrency: string;
priorityScore: number;
firstMilePickupAddress: string | null;
lastMileDeliveryAddress: string | null;
shippingLine: string | null;
pnrCode: string | null;
createdBy: string;
createdAt: string;
updatedAt: string;
}
export const BOOKING_STATUSES = [
"DRAFT",
"RFQ_SUBMITTED",
"QUOTATION_SENT",
"QUOTATION_APPROVED",
"QUOTATION_REJECTED",
"PENDING_APPROVAL",
"APPROVED",
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"PAID",
"IN_TRANSIT",
"COMPLETED",
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
] as const;
const customers = [
"Ethio Cargo Logistics",
"Djibouti Shipping PLC",
"Horn of Africa Traders",
"Addis Freight Forwarders",
"Red Sea Maritime Services",
"Dire Dawa Imports Ltd",
"Awash Agro Industry",
"Mieso Mineral Exports",
];
const yards = [
"Addis Ababa Dry Port",
"Mojo Inland Container Depot",
"Dire Dawa Freight Station",
"Djibouti Port Terminal",
"Adama Logistics Hub",
"Awash Cargo Center",
];
const serviceTypes = ["RAIL", "RAIL_AND_FORWARDING"];
const tradeDirections = ["EXPORT", "IMPORT", "DOMESTIC"];
const cargoTypes = ["Containerized", "Bulk", "Liquid", "Refrigerated", "Hazardous"];
const shippingLines = ["MSC", "CMA CGM", "Maersk", "COSCO", "Hapag-Lloyd", null];
function pick<T>(arr: T[], index: number): T {
return arr[index % arr.length];
}
function randDate(daysAgo: number): string {
const d = new Date(2026, 4, 28 - daysAgo);
return d.toISOString();
}
const now = Date.now();
const INITIAL_REQUESTS: BookingRequest[] = Array.from({ length: 25 }, (_, i) => {
const statusIndex = i % BOOKING_STATUSES.length;
const status = BOOKING_STATUSES[statusIndex];
const customer = pick(customers, i);
return {
id: String(i + 1),
reference: `EDR-BK-${String(2026001 + i).slice(-6)}`,
customer,
status,
scheduledDate: new Date(2026, 5, 1 + (i % 28)).toISOString().slice(0, 10),
totalAmount: 1500 + i * 320 + (i % 7) * 100,
paymentStatus: status === "PAID" || status === "COMPLETED" ? "PAID" : status === "CANCELLED" ? "REFUNDED" : "PENDING",
contractType: i % 5 === 0 ? "RENEWAL" : "NEW",
serviceType: pick(serviceTypes, i),
tradeDirection: pick(tradeDirections, i),
originYard: pick(yards, i),
destinationYard: pick(yards, i + 3),
cargoType: pick(cargoTypes, i),
cargoTotalWeightVgm: 10 + ((i * 7) % 90),
isHazardous: i % 7 === 0,
paymentCurrency: "USD",
priorityScore: i % 4 === 0 ? 3 : i % 3 === 0 ? 2 : 1,
firstMilePickupAddress: i % 3 === 0 ? "Bole Industrial Zone, Addis Ababa" : null,
lastMileDeliveryAddress: i % 4 === 0 ? "Port Boulevard, Djibouti City" : null,
shippingLine: pick(shippingLines, i),
pnrCode: i % 6 === 0 ? `PNR-${202600 + i}` : null,
createdBy: customer,
createdAt: randDate(30 - i),
updatedAt: randDate(2),
};
});
export function saveBookingRequestsToStorage(data: BookingRequest[]) {
if (typeof window !== "undefined" && window.localStorage) {
localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(data));
}
}
export function getBookingRequestById(id: string): BookingRequest | undefined {
const requests = getBookingRequests();
return requests.find((r) => r.id === id);
}
export function updateBookingRequestStatus(id: string, newStatus: (typeof BOOKING_STATUSES)[number]) {
const requests = getBookingRequests();
const idx = requests.findIndex((r) => r.id === id);
if (idx === -1) return;
requests[idx] = { ...requests[idx], status: newStatus, updatedAt: new Date().toISOString() };
saveBookingRequestsToStorage(requests);
}
export function getBookingRequests(): BookingRequest[] {
if (typeof window === "undefined" || !window.localStorage) {
return INITIAL_REQUESTS;
}
const data = localStorage.getItem("edr_backoffice_booking_requests");
if (!data) {
localStorage.setItem("edr_backoffice_booking_requests", JSON.stringify(INITIAL_REQUESTS));
return INITIAL_REQUESTS;
}
try {
return JSON.parse(data);
} catch {
return INITIAL_REQUESTS;
}
}

View File

@@ -100,7 +100,7 @@ const EmployeesPage = () => {
const responses = await Promise.all(
organizationIds.map((organizationId) =>
api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
`/backoffice/organizations/${organizationId}/employees`,
{
params: {
skip: 0,

View File

@@ -188,6 +188,46 @@ const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
return payload.items ?? payload.data ?? [];
};
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
const employeesByUserId = new Map<string, EmployeeRecord>();
for (const employee of employees) {
const userId = employee.user?.id;
if (!userId) {
employeesByUserId.set(employee.id, employee);
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedPositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
user: existing.user ?? employee.user,
employeePositions: mergedPositions,
});
}
return [...employeesByUserId.values()];
};
const toInternalKey = (value: string) =>
value
.trim()
@@ -611,9 +651,9 @@ const UserManagementPage = () => {
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/employees/${organizationId}/by-organization`,
`/backoffice/organizations/${organizationId}/employees`,
);
setOrgEmployees(getItems(response.data));
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
} catch {
setOrgEmployees([]);
} finally {
@@ -745,13 +785,19 @@ const UserManagementPage = () => {
const refreshSelectedUnit = useCallback(async () => {
if (!selectedUnitId) {
setPositions([]);
setPositionTypes([]);
setPositionTypesError(null);
setPositionMembers([]);
setUnitAdminUserIds(new Set());
return;
}
await Promise.all([loadPositions(selectedUnitId), loadUnitAdmins(selectedUnitId)]);
}, [loadPositions, loadUnitAdmins, selectedUnitId]);
await Promise.all([
loadPositions(selectedUnitId),
loadPositionTypes(selectedUnitId),
loadUnitAdmins(selectedUnitId),
]);
}, [loadPositionTypes, loadPositions, loadUnitAdmins, selectedUnitId]);
useEffect(() => {
void loadOrganizations();
@@ -772,7 +818,7 @@ const UserManagementPage = () => {
return;
}
setSelectedOrgId(null);
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
setSelectedOrgConfiguration(null);
setUnits([]);
setPositions([]);
@@ -780,6 +826,14 @@ const UserManagementPage = () => {
setOrgEmployees([]);
}, [selectedOrgId, visibleOrganizations]);
useEffect(() => {
if (!selectedOrgId) {
return;
}
void refreshSelectedOrg();
}, [refreshSelectedOrg, selectedOrgId]);
useEffect(() => {
if (!selectedOrgId) {
setUnits([]);
@@ -802,9 +856,23 @@ const UserManagementPage = () => {
return;
}
if (!units.some((unit) => unit.id === selectedUnitId)) {
setSelectedUnitId(null);
if (units.some((unit) => unit.id === selectedUnitId)) {
return;
}
setSelectedUnitId(units[0]?.id ?? null);
}, [selectedOrgId, selectedUnitId, units]);
useEffect(() => {
if (!selectedOrgId || !units.length) {
return;
}
if (selectedUnitId && units.some((unit) => unit.id === selectedUnitId)) {
return;
}
setSelectedUnitId(units[0]?.id ?? null);
}, [selectedOrgId, selectedUnitId, units]);
useEffect(() => {
@@ -820,6 +888,14 @@ const UserManagementPage = () => {
}
}, [selectedUnitId]);
useEffect(() => {
if (!selectedUnitId) {
return;
}
void refreshSelectedUnit();
}, [refreshSelectedUnit, selectedUnitId]);
useEffect(() => {
if (!selectedUnitId || !selectedPositionId) {
return;
@@ -882,15 +958,11 @@ const UserManagementPage = () => {
setSelectedPositionId(null);
setExpandedDepartmentIds(new Set());
setPositions([]);
setPositionTypes([]);
setPositionTypesError(null);
setPositionMembers([]);
setUnitAdminUserIds(new Set());
resetMessages();
try {
await Promise.all([loadPositions(unitId), loadUnitAdmins(unitId)]);
} catch {
// Individual loaders already handle their own error state.
}
};
const handleToggleDepartment = (departmentId: string) => {
@@ -1433,6 +1505,7 @@ const UserManagementPage = () => {
No organizations available.
</div>
)}
</aside>
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">

View File

@@ -1,11 +1,824 @@
import FeaturePlaceholder from "@/components/FeaturePlaceholder";
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { isAxiosError } from "axios";
import {
Badge,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@edr/ui-common";
import { Network, RefreshCw, Search, UserCheck, UserMinus, Users } from "lucide-react";
import { api } from "@/auth/http";
import { useAuth } from "@/auth/useAuth";
interface LocaleText {
en?: string;
am?: string;
}
interface OrganizationRecord {
id: string;
key: string;
name: LocaleText;
}
interface EmployeeUserRecord {
id: string;
name?: LocaleText;
email?: string;
phoneNumber?: string;
username?: string;
}
interface EmployeePositionSummary {
id: string;
position?: {
id: string;
name?: LocaleText;
};
}
interface EmployeeRecord {
id: string;
name?: LocaleText;
user?: EmployeeUserRecord;
status?: string;
employeePositions?: EmployeePositionSummary[];
}
interface RoleRecord {
id: string;
key: string;
name: LocaleText;
}
interface UserFormState {
nameEn: string;
nameAm: string;
email: string;
username: string;
phoneNumber: string;
assignOrganizationAdmin: boolean;
}
interface ListResponse<T> {
items?: T[];
data?: T[];
}
const RESERVED_ROLE_KEYS = new Set(["super_admin", "organization_admin", "unit_admin"]);
const emptyUserForm: UserFormState = {
nameEn: "",
nameAm: "",
email: "",
username: "",
phoneNumber: "",
assignOrganizationAdmin: false,
};
const inputClassName =
"w-full rounded-xl border border-border bg-background px-3 py-2.5 text-sm text-foreground outline-none transition focus:border-emerald-500 focus:ring-2 focus:ring-emerald-100 dark:focus:ring-emerald-950";
const buttonClassName =
"inline-flex items-center justify-center gap-2 rounded-xl px-3 py-2 text-sm font-medium transition disabled:cursor-not-allowed disabled:opacity-60";
const getLocaleLabel = (value?: LocaleText | null, fallback = "Unnamed") => {
if (!value) {
return fallback;
}
return value.en ?? value.am ?? fallback;
};
const getItems = <T,>(payload: ListResponse<T> | T[] | undefined | null) => {
if (!payload) {
return [] as T[];
}
if (Array.isArray(payload)) {
return payload;
}
return payload.items ?? payload.data ?? [];
};
const mergeEmployeesByUser = (employees: EmployeeRecord[]) => {
const employeesByUserId = new Map<string, EmployeeRecord>();
for (const employee of employees) {
const userId = employee.user?.id;
if (!userId) {
employeesByUserId.set(employee.id, employee);
continue;
}
const existing = employeesByUserId.get(userId);
if (!existing) {
employeesByUserId.set(userId, employee);
continue;
}
const existingPositions = existing.employeePositions ?? [];
const nextPositions = employee.employeePositions ?? [];
const mergedPositions = Array.from(
new Map(
[...existingPositions, ...nextPositions].map((position) => [position.id, position]),
).values(),
);
employeesByUserId.set(userId, {
...existing,
...employee,
id: existing.id,
name: existing.name ?? employee.name,
status: existing.status ?? employee.status,
user: existing.user ?? employee.user,
employeePositions: mergedPositions,
});
}
return [...employeesByUserId.values()];
};
const getErrorMessage = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (typeof message === "string") {
return message;
}
if (Array.isArray(message) && typeof message[0] === "string") {
return message[0];
}
}
return error instanceof Error ? error.message : fallback;
};
const Field = ({ label, children }: { label: string; children: ReactNode }) => (
<label className="flex flex-col gap-2 text-sm">
<span className="font-medium text-foreground">{label}</span>
{children}
</label>
);
const ManagementDialog = ({
open,
title,
description,
onOpenChange,
children,
}: {
open: boolean;
title: string;
description?: string;
onOpenChange: (open: boolean) => void;
children: ReactNode;
}) => (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? <DialogDescription>{description}</DialogDescription> : null}
</DialogHeader>
{children}
</DialogContent>
</Dialog>
);
const UsersPage = () => {
const { user } = useAuth();
const [organizations, setOrganizations] = useState<OrganizationRecord[]>([]);
const [orgEmployees, setOrgEmployees] = useState<EmployeeRecord[]>([]);
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
const [orgUserSearch, setOrgUserSearch] = useState("");
const [createUserForm, setCreateUserForm] = useState<UserFormState>(emptyUserForm);
const [availableRoles, setAvailableRoles] = useState<RoleRecord[]>([]);
const [roleIds, setRoleIds] = useState<string[]>([]);
const [selectedRoleUser, setSelectedRoleUser] = useState<EmployeeRecord | null>(null);
const [loading, setLoading] = useState(true);
const [orgEmployeesLoading, setOrgEmployeesLoading] = useState(false);
const [rolesLoading, setRolesLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const [isCreateUserOpen, setIsCreateUserOpen] = useState(false);
const [isManageRolesOpen, setIsManageRolesOpen] = useState(false);
const isSuperAdmin = Boolean(user?.roles?.some((role) => role.key === "super_admin"));
const allowedOrgIds = useMemo(
() => new Set((user?.employee ?? []).map((employee) => employee.organizationId).filter(Boolean)),
[user?.employee],
);
const visibleOrganizations = useMemo(() => {
if (isSuperAdmin) {
return organizations;
}
return organizations.filter((organization) => allowedOrgIds.has(organization.id));
}, [allowedOrgIds, isSuperAdmin, organizations]);
const selectedOrganization = useMemo(
() => visibleOrganizations.find((item) => item.id === selectedOrgId) ?? null,
[selectedOrgId, visibleOrganizations],
);
const filteredOrgEmployees = useMemo(() => {
const query = orgUserSearch.trim().toLowerCase();
return orgEmployees.filter((employee) => {
const label = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
).toLowerCase();
const email = employee.user?.email?.toLowerCase() ?? "";
const username = employee.user?.username?.toLowerCase() ?? "";
if (!query) {
return true;
}
return label.includes(query) || email.includes(query) || username.includes(query);
});
}, [orgEmployees, orgUserSearch]);
const resetMessages = () => {
setActionError(null);
setActionSuccess(null);
};
const loadOrganizations = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const response = await api.get<ListResponse<OrganizationRecord>>("/organizations");
setOrganizations(getItems(response.data));
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load organizations."));
} finally {
setLoading(false);
}
}, []);
const loadOrgEmployees = useCallback(async (organizationId: string) => {
setOrgEmployeesLoading(true);
try {
const response = await api.get<ListResponse<EmployeeRecord>>(
`/backoffice/organizations/${organizationId}/employees`,
);
setOrgEmployees(mergeEmployeesByUser(getItems(response.data)));
} catch {
setOrgEmployees([]);
} finally {
setOrgEmployeesLoading(false);
}
}, []);
useEffect(() => {
void loadOrganizations();
}, [loadOrganizations]);
useEffect(() => {
if (!visibleOrganizations.length) {
setSelectedOrgId(null);
setOrgEmployees([]);
return;
}
if (selectedOrgId && visibleOrganizations.some((organization) => organization.id === selectedOrgId)) {
return;
}
setSelectedOrgId(visibleOrganizations[0]?.id ?? null);
}, [selectedOrgId, visibleOrganizations]);
useEffect(() => {
if (!selectedOrgId) {
setOrgEmployees([]);
return;
}
void loadOrgEmployees(selectedOrgId);
}, [loadOrgEmployees, selectedOrgId]);
const handleRefresh = async () => {
resetMessages();
await Promise.all([
loadOrganizations(),
selectedOrgId ? loadOrgEmployees(selectedOrgId) : Promise.resolve(),
]);
};
const handleSelectOrganization = async (organizationId: string) => {
setSelectedOrgId(organizationId);
setOrgEmployees([]);
resetMessages();
try {
await loadOrgEmployees(organizationId);
} catch {
// Loader already handles fallback state.
}
};
const openCreateUserDialog = () => {
if (!selectedOrgId) {
setActionError("Select an organization before adding a user.");
return;
}
setCreateUserForm(emptyUserForm);
resetMessages();
setIsCreateUserOpen(true);
};
const openManageRolesDialog = async (employee: EmployeeRecord) => {
if (!selectedOrgId || !employee.user?.id) {
return;
}
setRolesLoading(true);
resetMessages();
setSelectedRoleUser(employee);
setIsManageRolesOpen(true);
try {
const [rolesResponse, assignedResponse] = await Promise.all([
api.get<ListResponse<RoleRecord>>("/roles"),
api.get<RoleRecord[]>(`/backoffice/organizations/${selectedOrgId}/employee-users/${employee.user.id}/roles`),
]);
const roles = getItems(rolesResponse.data).filter((role) => !RESERVED_ROLE_KEYS.has(role.key));
setAvailableRoles(roles);
setRoleIds(getItems(assignedResponse.data).map((role) => role.id));
} catch (error) {
setActionError(getErrorMessage(error, "Failed to load user roles."));
setAvailableRoles([]);
setRoleIds([]);
} finally {
setRolesLoading(false);
}
};
const handleCreateUser = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!selectedOrgId) {
setActionError("Select an organization before adding a user.");
return;
}
setSubmitting(true);
resetMessages();
try {
const response = await api.post<EmployeeRecord>(
`/backoffice/organizations/${selectedOrgId}/users`,
{
username: createUserForm.username.trim(),
phoneNumber: createUserForm.phoneNumber.trim(),
email: createUserForm.email.trim(),
name: {
am: createUserForm.nameAm.trim(),
en: createUserForm.nameEn.trim(),
},
assignOrganizationAdmin: createUserForm.assignOrganizationAdmin,
},
);
const shouldAssignOrganizationAdmin = createUserForm.assignOrganizationAdmin;
setCreateUserForm(emptyUserForm);
setIsCreateUserOpen(false);
setActionSuccess(
shouldAssignOrganizationAdmin
? "User created as organization admin. Default password: 12345678."
: "User created. Default password: 12345678.",
);
await loadOrgEmployees(selectedOrgId);
if (!shouldAssignOrganizationAdmin) {
await openManageRolesDialog(response.data);
}
} catch (error) {
setActionError(getErrorMessage(error, "Failed to create user."));
} finally {
setSubmitting(false);
}
};
const handleSaveRoles = async () => {
if (!selectedOrgId || !selectedRoleUser?.user?.id) {
return;
}
setSubmitting(true);
resetMessages();
try {
await api.put(
`/backoffice/organizations/${selectedOrgId}/employee-users/${selectedRoleUser.user.id}/roles`,
{ roleIds },
);
setActionSuccess("User roles updated.");
setIsManageRolesOpen(false);
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user roles."));
} finally {
setSubmitting(false);
}
};
const handleToggleUserActivation = async (employee: EmployeeRecord) => {
if (!employee.user?.id) {
return;
}
const isInactive = employee.status?.toLowerCase() === "inactive";
setSubmitting(true);
resetMessages();
try {
await api.patch(`/users/${isInactive ? "activate-user" : "deactivate-user"}/${employee.user.id}`);
setActionSuccess(isInactive ? "User activated." : "User deactivated.");
if (selectedOrgId) {
await loadOrgEmployees(selectedOrgId);
}
} catch (error) {
setActionError(getErrorMessage(error, "Failed to update user status."));
} finally {
setSubmitting(false);
}
};
return (
<FeaturePlaceholder
title="Users"
description="Manage backoffice user accounts, activation state, and directory records for internal freight teams."
/>
<section className="space-y-6 bg-background p-6 text-foreground">
<div className="rounded-3xl border border-border bg-linear-to-br from-emerald-100 via-card to-background p-6 shadow-sm dark:from-emerald-950/30 dark:via-card dark:to-background">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300">
<Network className="h-6 w-6" />
</div>
<div className="space-y-2">
<p className="text-sm font-medium uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-300">
User management
</p>
<h1 className="text-2xl font-semibold text-foreground">Users</h1>
<p className="max-w-3xl text-sm text-muted-foreground">
Create organization users, activate or deactivate access, and assign organization-scoped roles.
</p>
</div>
</div>
<button
type="button"
onClick={() => void handleRefresh()}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
<RefreshCw className="h-4 w-4" />
Refresh
</button>
</div>
</div>
{actionSuccess ? (
<div className="rounded-2xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-900/60 dark:bg-emerald-950/30 dark:text-emerald-200">
{actionSuccess}
</div>
) : null}
{actionError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{actionError}
</div>
) : null}
{loadError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/40 dark:text-rose-200">
{loadError}
</div>
) : null}
{loading ? (
<div className="rounded-3xl border border-border bg-card p-6 text-sm text-muted-foreground shadow-sm">
Loading users workspace...
</div>
) : (
<div className="grid gap-6 xl:grid-cols-4">
<aside className="rounded-3xl border border-border bg-card p-5 shadow-sm">
<div className="mb-5">
<h2 className="text-lg font-semibold text-card-foreground">Organization</h2>
<p className="text-sm text-muted-foreground">
{isSuperAdmin ? "All organizations" : "Assigned organizations"}
</p>
</div>
{visibleOrganizations.length ? (
<ul className="space-y-2">
{visibleOrganizations.map((organization) => {
const isActive = selectedOrgId === organization.id;
const isDisabled = !isSuperAdmin;
return (
<li key={organization.id}>
<button
type="button"
disabled={isDisabled}
onClick={() => void handleSelectOrganization(organization.id)}
className={`flex w-full items-center justify-between gap-2 rounded-2xl border px-4 py-3 text-left transition ${
isActive
? "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/40 dark:text-emerald-100"
: "border-border bg-card text-card-foreground hover:border-emerald-200 hover:bg-emerald-50/80 dark:hover:bg-slate-900"
} ${isDisabled ? "cursor-default" : ""}`}
>
<div className="min-w-0">
<div className="truncate font-medium">
{getLocaleLabel(organization.name, organization.key)}
</div>
<div className="truncate text-xs text-muted-foreground">{organization.key}</div>
</div>
{!isSuperAdmin ? <Badge className="bg-sky-100 text-sky-700">Assigned</Badge> : null}
</button>
</li>
);
})}
</ul>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No organizations available.
</div>
)}
<button
type="button"
disabled={!selectedOrgId || submitting}
onClick={openCreateUserDialog}
className={`${buttonClassName} mt-4 w-full bg-emerald-600 text-white hover:bg-emerald-700`}
>
Add user to organization
</button>
</aside>
<section className="rounded-3xl border border-border bg-card p-5 shadow-sm xl:col-span-3">
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-card-foreground">Users</h2>
<p className="text-sm text-muted-foreground">
{selectedOrganization
? `Manage users in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}`
: "Select an organization"}
</p>
</div>
<div className="rounded-full border border-border bg-muted px-3 py-1 text-sm font-medium text-muted-foreground">
{orgEmployees.length} users
</div>
</div>
<div className="relative mb-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
className={`${inputClassName} pl-9`}
value={orgUserSearch}
onChange={(event) => setOrgUserSearch(event.target.value)}
placeholder="Search users"
/>
</div>
{orgEmployeesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading users...
</div>
) : filteredOrgEmployees.length ? (
<div className="space-y-3">
{filteredOrgEmployees.map((employee) => {
const userId = employee.user?.id;
const displayName = getLocaleLabel(
employee.name ?? employee.user?.name,
employee.user?.email ?? employee.id,
);
const assignedPositions = employee.employeePositions
?.map((position) => getLocaleLabel(position.position?.name, position.position?.id ?? ""))
.filter(Boolean)
.join(", ");
return (
<article
key={employee.id}
className="rounded-2xl border border-border bg-background p-4 shadow-sm"
>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-base font-semibold text-foreground">{displayName}</h3>
{employee.status ? (
<Badge className="bg-gray-100 text-gray-700">{employee.status}</Badge>
) : null}
</div>
<div className="text-sm text-muted-foreground">
{employee.user?.email || employee.user?.username || "No contact info"}
</div>
<div className="text-sm text-muted-foreground">
{assignedPositions ? `Current positions: ${assignedPositions}` : "No positions assigned."}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!userId || submitting}
onClick={() => void handleToggleUserActivation(employee)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
{employee.status?.toLowerCase() === "inactive" ? (
<>
<UserCheck className="h-4 w-4" />
Activate
</>
) : (
<>
<UserMinus className="h-4 w-4" />
Deactivate
</>
)}
</button>
<button
type="button"
disabled={!userId || !selectedOrgId || submitting}
onClick={() => void openManageRolesDialog(employee)}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
<Users className="h-4 w-4" />
Manage roles
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
{selectedOrganization
? "No users found for this organization."
: "Select an organization to load users."}
</div>
)}
</section>
</div>
)}
<ManagementDialog
open={isCreateUserOpen}
onOpenChange={setIsCreateUserOpen}
title="Add user"
description={
selectedOrganization
? `Create a loginable user in ${getLocaleLabel(selectedOrganization.name, selectedOrganization.key)}. Default password: 12345678.`
: "Create a loginable user in the selected organization."
}
>
<form className="space-y-4" onSubmit={handleCreateUser}>
<Field label="English name">
<input
className={inputClassName}
value={createUserForm.nameEn}
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameEn: event.target.value }))}
/>
</Field>
<Field label="Amharic name">
<input
className={inputClassName}
value={createUserForm.nameAm}
onChange={(event) => setCreateUserForm((current) => ({ ...current, nameAm: event.target.value }))}
/>
</Field>
<Field label="Email">
<input
className={inputClassName}
type="email"
value={createUserForm.email}
onChange={(event) => setCreateUserForm((current) => ({ ...current, email: event.target.value }))}
/>
</Field>
<Field label="Username">
<input
className={inputClassName}
value={createUserForm.username}
onChange={(event) => setCreateUserForm((current) => ({ ...current, username: event.target.value }))}
/>
</Field>
<Field label="Phone number">
<input
className={inputClassName}
value={createUserForm.phoneNumber}
onChange={(event) => setCreateUserForm((current) => ({ ...current, phoneNumber: event.target.value }))}
/>
</Field>
<label className="flex items-start gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm">
<input
type="checkbox"
checked={createUserForm.assignOrganizationAdmin}
onChange={(event) =>
setCreateUserForm((current) => ({
...current,
assignOrganizationAdmin: event.target.checked,
}))
}
/>
<div>
<div className="font-medium text-foreground">Create as organization admin</div>
<div className="text-xs text-muted-foreground">
Also assigns built-in org admin access and the freight org manager role.
</div>
</div>
</label>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsCreateUserOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button type="submit" disabled={submitting} className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}>
Create user
</button>
</div>
</form>
</ManagementDialog>
<ManagementDialog
open={isManageRolesOpen}
onOpenChange={setIsManageRolesOpen}
title="Manage roles"
description={
selectedRoleUser
? `Assign organization-scoped roles for ${getLocaleLabel(selectedRoleUser.name ?? selectedRoleUser.user?.name, selectedRoleUser.user?.email ?? selectedRoleUser.id)}.`
: undefined
}
>
<div className="space-y-4">
{rolesLoading ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
Loading roles...
</div>
) : (
<div className="max-h-[420px] space-y-2 overflow-y-auto pr-1">
{availableRoles.map((role) => (
<label
key={role.id}
className="flex items-center gap-3 rounded-2xl border border-border bg-background px-4 py-3 text-sm"
>
<input
type="checkbox"
checked={roleIds.includes(role.id)}
onChange={(event) => {
setRoleIds((current) =>
event.target.checked
? [...current, role.id]
: current.filter((currentRoleId) => currentRoleId !== role.id),
);
}}
/>
<div>
<div className="font-medium text-foreground">{getLocaleLabel(role.name, role.key)}</div>
<div className="text-xs text-muted-foreground">{role.key}</div>
</div>
</label>
))}
{!availableRoles.length ? (
<div className="rounded-2xl border border-dashed border-border bg-muted px-4 py-8 text-center text-sm text-muted-foreground">
No assignable roles available.
</div>
) : null}
</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setIsManageRolesOpen(false)}
className={`${buttonClassName} border border-border bg-card text-card-foreground hover:bg-accent hover:text-accent-foreground`}
>
Cancel
</button>
<button
type="button"
disabled={submitting || rolesLoading}
onClick={() => void handleSaveRoles()}
className={`${buttonClassName} bg-emerald-600 text-white hover:bg-emerald-700`}
>
Save roles
</button>
</div>
</div>
</ManagementDialog>
</section>
);
};

View File

@@ -1,10 +0,0 @@
// src/pages/ruleEngine/RuleEngine.tsx
import ContractTypePage from "@/components/ruleEngine/ContractType";
export const RuleEnginePage = () => {
return (
<div className="p-6">
<ContractTypePage />
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Navigate, useParams } from "react-router-dom";
import {
DEFAULT_CONFIGURATION_SLUG,
getRuleEngineResource,
ruleEngineResourcePath,
} from "@/pages/ruleEngine/config/resources";
/** Redirects old `/dashboard/rule-engine/:resource` URLs to category-based paths. */
const RuleEngineLegacyRedirect = () => {
const { resource } = useParams<{ resource: string }>();
const config = resource ? getRuleEngineResource(resource) : undefined;
if (!config) {
return <Navigate to={`/dashboard/configuration/${DEFAULT_CONFIGURATION_SLUG}`} replace />;
}
return <Navigate to={ruleEngineResourcePath(config.slug)} replace />;
};
export default RuleEngineLegacyRedirect;

View File

@@ -0,0 +1,455 @@
import { useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import type { ColumnDef } from "@tanstack/react-table";
import { Loader2 } from "lucide-react";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
import RuleEngineRecordActions from "@/components/ruleEngine/RuleEngineRecordActions";
import RuleEngineToolbar from "@/components/ruleEngine/RuleEngineToolbar";
import { formatCell } from "@/components/ruleEngine/ruleEngineFormat";
import {
ruleEngineField,
ruleEngineSurface,
ruleEngineTable,
} from "@/components/ruleEngine/ruleEngineStyles";
import { useRuleEngineViewMode } from "@/components/ruleEngine/useRuleEngineViewMode";
import {
DEFAULT_CONFIGURATION_SLUG,
DEFAULT_RULES_SLUG,
RULE_ENGINE_CATEGORY_BASE_PATH,
RULE_ENGINE_SELECT_NONE,
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import {
useApprovalChain,
useCargoTypeParentOptions,
useContainerTypeOptions,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
} from "@/hooks/rule-engine/useRuleEngine";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
Button,
Card,
DataTable,
DataTableFooter,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Label,
getCoreRowModel,
usePagination,
useReactTable,
} from "@edr/ui-common";
const pathCategory = (pathname: string): RuleEngineNavCategory | undefined => {
const normalized = pathname.toLowerCase();
if (normalized.startsWith("/dashboard/configuration")) return "configuration";
if (normalized.startsWith("/dashboard/rules")) return "rules";
return undefined;
};
const RuleEngineResourcePage = () => {
const { resource: resourceSlug } = useParams<{ resource: string }>();
const location = useLocation();
const category = pathCategory(location.pathname);
const config = resourceSlug ? getRuleEngineResource(resourceSlug) : undefined;
const defaultPath = category
? `${RULE_ENGINE_CATEGORY_BASE_PATH[category]}/${
category === "rules" ? DEFAULT_RULES_SLUG : DEFAULT_CONFIGURATION_SLUG
}`
: `${RULE_ENGINE_CATEGORY_BASE_PATH.configuration}/${DEFAULT_CONFIGURATION_SLUG}`;
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(null);
const [chainOpen, setChainOpen] = useState(false);
const [approveTarget, setApproveTarget] = useState<RuleEngineRecord | null>(null);
const [ceoId, setCeoId] = useState("");
const { viewMode, setViewMode } = useRuleEngineViewMode(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const listParams = useMemo(
() => ({
search: config?.supportsSearch ? search.trim() || undefined : undefined,
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
}),
[config?.supportsSearch, search, pagination.pageIndex, pagination.pageSize],
);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
listParams,
);
const { create, update, remove } = useRuleEngineMutations(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
);
const { submit, approve } = useRateWorkflow();
const { data: chainData, isLoading: chainLoading } = useApprovalChain(
chainOpen && config?.slug === "approval-rules",
);
const editingId = editing?.id ? String(editing.id) : undefined;
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
const { data: containerTypeOptions, isLoading: containerTypeOptionsLoading } =
useContainerTypeOptions(config?.slug === "rates");
const formFields = useMemo(() => {
if (!config) return [];
return config.formFields.map((field) =>
config.slug === "cargo-types" && field.name === "parentGroupId"
? {
...field,
options:
cargoParentOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
}
: config.slug === "rates" && field.name === "containerTypeId"
? {
...field,
options:
containerTypeOptions ?? [
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
],
}
: field,
);
}, [config, cargoParentOptions, containerTypeOptions]);
const rows = data?.data ?? [];
const meta = data?.meta;
const pageCount = meta?.totalPages ?? 1;
const filteredRows = useMemo(() => {
if (config?.supportsSearch || !search.trim()) return rows;
const q = search.trim().toLowerCase();
return rows.filter((row) =>
JSON.stringify(row).toLowerCase().includes(q),
);
}, [rows, search, config?.supportsSearch]);
const paginationState = useMemo(
() => ({
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount,
totalCount: meta?.total ?? filteredRows.length,
}),
[filteredRows.length, meta?.total, pageCount, pagination.pageIndex, pagination.pageSize],
);
const cardTable = useReactTable({
data: filteredRows,
columns: [] as ColumnDef<RuleEngineRecord>[],
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
});
const columns = useMemo((): ColumnDef<RuleEngineRecord>[] => {
if (!config) return [];
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
const base: ColumnDef<RuleEngineRecord>[] = config.columns.map((col) => ({
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
}));
base.push({
id: "actions",
header: "Details",
size: 120,
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<div onClick={(e) => e.stopPropagation()}>
<RuleEngineRecordActions
record={row.original}
config={config}
onEdit={(record) => {
setEditing(record);
setFormOpen(true);
}}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
onApproveRate={setApproveTarget}
/>
</div>
),
});
return base;
}, [config, submit]);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
if (!resourceSlug || !category) {
return <Navigate to={defaultPath} replace />;
}
if (!config || config.category !== category) {
return <Navigate to={defaultPath} replace />;
}
const openCreate = () => {
setEditing(null);
setFormOpen(true);
};
const openEdit = (record: RuleEngineRecord) => {
setEditing(record);
setFormOpen(true);
};
const handleFormSubmit = (values: Record<string, unknown>) => {
if (editing?.id) {
update.mutate(
{ id: editing.id, payload: values },
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
} else {
create.mutate(values, {
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
});
}
};
const itemLabel = config.label.toLowerCase();
return (
<div>
<Card className={ruleEngineSurface.pageCard}>
<div className={ruleEngineSurface.pageCardToolbar}>
<RuleEngineToolbar
search={search}
onSearchChange={(v) => {
setSearch(v);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
searchPlaceholder={config.searchPlaceholder}
onAdd={openCreate}
addLabel={`Add ${config.label.replace(/s$/, "")}`}
viewMode={viewMode}
onViewModeChange={setViewMode}
/>
</div>
{viewMode === "table" ? (
<DataTable
columns={columns}
data={filteredRows}
status={tableStatus}
error={
isError
? {
message: "Failed to load data",
description:
error instanceof Error ? error.message : "Unknown error",
}
: undefined
}
emptyMessage={`No ${itemLabel} found.`}
pagination={paginationState}
tableOptions={{
manualPagination: true,
pageCount,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none [&_[data-slot=table-row]]:border-border"
footerClassName="border-t border-border bg-card"
footer={({ table, pagination: footerPagination }) => (
<DataTableFooter
table={table}
pagination={footerPagination}
options={{
labels: {
showing: "Showing",
ofLabel: "of",
items: itemLabel,
},
}}
/>
)}
/>
) : (
<RuleEngineCardGrid
config={config}
rows={filteredRows}
status={tableStatus}
emptyMessage={`No ${itemLabel} found.`}
itemLabel={itemLabel}
table={cardTable}
pagination={paginationState}
onEdit={openEdit}
onDelete={setDeleteTarget}
onViewChain={
config.slug === "approval-rules" ? () => setChainOpen(true) : undefined
}
onSubmitRate={(id) => submit.mutate(id)}
onApproveRate={setApproveTarget}
/>
)}
</Card>
<RuleEngineFormDialog
open={formOpen}
onOpenChange={setFormOpen}
title={editing ? `Edit ${config.label.replace(/s$/, "")}` : `Add ${config.label.replace(/s$/, "")}`}
description={
editing
? `Update this ${config.label.toLowerCase()} record.`
: `Create a new ${config.label.toLowerCase()} record.`
}
fields={formFields}
initialRecord={editing}
isSubmitting={create.isPending || update.isPending}
selectOptionsLoading={
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
(config.slug === "rates" && containerTypeOptionsLoading)
}
onSubmit={handleFormSubmit}
/>
<Dialog open={Boolean(deleteTarget)} onOpenChange={(o) => !o && setDeleteTarget(null)}>
<DialogContent className={ruleEngineSurface.dialogSm}>
<DialogHeader>
<DialogTitle>Delete record?</DialogTitle>
<DialogDescription>
This will soft-delete the selected {config.label.toLowerCase()} record.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
Cancel
</Button>
<Button
variant="destructive"
disabled={remove.isPending}
onClick={() => {
if (!deleteTarget) return;
remove.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
}}
>
{remove.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Delete"}
</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(approveTarget)} onOpenChange={(o) => !o && setApproveTarget(null)}>
<DialogContent className={ruleEngineSurface.dialogSm}>
<DialogHeader>
<DialogTitle>Approve rate</DialogTitle>
<DialogDescription>Enter the CEO staff ID to approve this rate.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="ceoId" className={ruleEngineField.label}>
CEO staff ID
</Label>
<Input
id="ceoId"
value={ceoId}
onChange={(e) => setCeoId(e.target.value)}
placeholder="UUID"
className={ruleEngineField.input}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setApproveTarget(null)}>
Cancel
</Button>
<Button
disabled={!ceoId.trim() || approve.isPending}
onClick={() => {
if (!approveTarget) return;
approve.mutate(
{ id: approveTarget.id, payload: { approvedByCeoId: ceoId.trim() } },
{
onSuccess: () => {
setApproveTarget(null);
setCeoId("");
},
},
);
}}
>
{approve.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : "Approve"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={chainOpen} onOpenChange={setChainOpen}>
<DialogContent className={ruleEngineSurface.dialog}>
<DialogHeader>
<DialogTitle>Approval chain</DialogTitle>
<DialogDescription>Configured approval steps from the API.</DialogDescription>
</DialogHeader>
{chainLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : (
<ol className="space-y-3">
{(chainData ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No approval rules configured.</p>
) : (
(chainData ?? []).map((step, index) => (
<li
key={String(step.id ?? index)}
className="rounded-md border border-border bg-muted/30 px-4 py-3 text-sm"
>
<p className="font-medium text-foreground">
Step {String(step.stepOrder ?? index + 1)}: {String(step.actionLabel ?? "")}
</p>
<p className="text-muted-foreground">
Role: {String(step.requiredRole ?? "—")}
</p>
</li>
))
)}
</ol>
)}
</DialogContent>
</Dialog>
</div>
);
};
export default RuleEngineResourcePage;

View File

@@ -0,0 +1,423 @@
import type { SidebarItem } from "@/components/layout/types";
import type { RuleEngineResourceSlug } from "@/types/rule-engine";
export type RuleEngineNavCategory = "configuration" | "rules";
export type ColumnFormat = "text" | "code" | "boolean" | "activeBadge" | "rateStatus" | "date" | "number";
export type FormFieldType = "text" | "number" | "boolean" | "date" | "select" | "textarea";
export interface ResourceColumn {
id: string;
header: string;
accessorKey: string;
format?: ColumnFormat;
}
/** Radix Select cannot use empty string as an item value; use this for optional "none" choices. */
export const RULE_ENGINE_SELECT_NONE = "__none__";
export interface FormFieldDef {
name: string;
label: string;
type: FormFieldType;
required?: boolean;
optional?: boolean;
options?: { label: string; value: string }[];
placeholder?: string;
}
export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug;
label: string;
subtitle: string;
category: RuleEngineNavCategory;
searchPlaceholder: string;
columns: ResourceColumn[];
formFields: FormFieldDef[];
supportsSearch?: boolean;
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
cardSubtitleKey?: string;
/** Code badge on card header (inferred from code column when omitted). */
cardCodeKey?: string;
}
export const RULE_ENGINE_CATEGORY_BASE_PATH: Record<RuleEngineNavCategory, string> = {
configuration: "/dashboard/configuration",
rules: "/dashboard/rules",
};
const TRADE_DIRECTIONS = [
{ label: "Import", value: "IMPORT" },
{ label: "Export", value: "EXPORT" },
{ label: "Both", value: "BOTH" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
{ label: "CEO", value: "CEO" },
];
const SURCHARGE_TRIGGERS = [
{ label: "Hazardous cargo", value: "CARGO_FLAG_HAZARDOUS" },
{ label: "Reefer cargo", value: "CARGO_FLAG_REEFER" },
{ label: "VGM exceeds limit", value: "VGM_EXCEEDS_LIMIT" },
{ label: "Shipping line mapped", value: "SHIPPING_LINE_MAPPED" },
{ label: "Consolidation enabled", value: "CONSOLIDATION_ENABLED" },
];
const RATE_TYPES = [
"CONTAINER_IMPORT",
"CONTAINER_EXPORT",
"BULK_IMPORT",
"BULK_EXPORT",
"INTERCITY_BULK",
"INTERCITY_CONTAINER",
"FIRST_MILE",
"LAST_MILE",
"DEMURRAGE",
"LASHING",
"DOUBLE_HANDLING",
"CONTAINER_WITH_RETURN",
"CANCELLATION_FEE",
"OVERWEIGHT_PER_TON",
"HAZARD_SURCHARGE",
"REEFER_SURCHARGE",
"PIL_EXTRA_FEE",
].map((v) => ({ label: v.replace(/_/g, " "), value: v }));
const RATE_UNITS = ["PER_WAGON", "PER_TON", "PER_CONTAINER", "PER_KM", "FLAT"].map((v) => ({
label: v.replace(/_/g, " "),
value: v,
}));
const CURRENCIES = [
{ label: "ETB", value: "ETB" },
{ label: "USD", value: "USD" },
];
const codeColumn = (key: string, header = "Code"): ResourceColumn => ({
id: key,
header,
accessorKey: key,
format: "code",
});
const activeColumn: ResourceColumn = {
id: "isActive",
header: "Status",
accessorKey: "isActive",
format: "activeBadge",
};
export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
{
slug: "cargo-types",
label: "Cargo Types",
category: "configuration",
subtitle: "Manage freight cargo classification and approval rules",
searchPlaceholder: "Search cargo types by name or code...",
supportsSearch: true,
columns: [
codeColumn("code"),
{ id: "cargoTypeName", header: "Name", accessorKey: "cargoTypeName" },
{
id: "requiresDirectorApproval",
header: "Director approval",
accessorKey: "requiresDirectorApproval",
format: "boolean",
},
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
activeColumn,
],
formFields: [
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
{
name: "parentGroupId",
label: "Parent group",
type: "select",
optional: true,
placeholder: "Select parent cargo type (optional)",
},
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
slug: "container-types",
label: "Container Types",
category: "configuration",
subtitle: "Configure container sizes and wagon capacity",
searchPlaceholder: "Search container types...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "sizeFt", header: "Size (ft)", accessorKey: "sizeFt", format: "number" },
{ id: "wagonsPerUnit", header: "Wagons / unit", accessorKey: "wagonsPerUnit", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
{ name: "wagonsPerUnit", label: "Wagons per unit", type: "number", required: true },
{ name: "isReefer", label: "Reefer", type: "boolean" },
{ name: "isOpenTop", label: "Open top", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
slug: "priority-rules",
label: "Priority Rules",
category: "rules",
subtitle: "Booking priority scoring rules",
searchPlaceholder: "Search priority rules...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "score", header: "Score", accessorKey: "score", format: "number" },
{ id: "conditionCurrency", header: "Currency", accessorKey: "conditionCurrency" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "score", label: "Score", type: "number", required: true },
{ name: "conditionCurrency", label: "Condition currency", type: "text", placeholder: "USD (optional)" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "service-types",
label: "Service Types",
category: "configuration",
subtitle: "Freight service offerings and booking options",
searchPlaceholder: "Search service types...",
supportsSearch: true,
columns: [
codeColumn("code"),
{ id: "serviceName", header: "Service name", accessorKey: "serviceName" },
{ id: "priorityBonusPoints", header: "Bonus pts", accessorKey: "priorityBonusPoints", format: "number" },
activeColumn,
],
formFields: [
{ name: "serviceName", label: "Service name", type: "text", required: true },
{ name: "description", label: "Description", type: "textarea" },
{ name: "canBeBookedAlone", label: "Can be booked alone", type: "boolean" },
{ name: "includesFirstMile", label: "Includes first mile", type: "boolean" },
{ name: "includesLastMile", label: "Includes last mile", type: "boolean" },
{ name: "includesCustoms", label: "Includes customs", type: "boolean" },
{ name: "priorityBonusPoints", label: "Priority bonus points", type: "number" },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
slug: "surcharge-types",
label: "Surcharge Types",
category: "configuration",
subtitle: "Auto-applied surcharge definitions",
searchPlaceholder: "Search surcharge types...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "triggerCondition", header: "Trigger", accessorKey: "triggerCondition" },
{ id: "rateId", header: "Rate ID", accessorKey: "rateId" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{
name: "triggerCondition",
label: "Trigger condition",
type: "select",
required: true,
options: SURCHARGE_TRIGGERS,
},
{ name: "rateId", label: "Rate ID", type: "text", required: true, placeholder: "UUID of LIVE rate" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "weight-limit-rules",
label: "Weight Limit Rules",
category: "rules",
subtitle: "VGM limits by container and trade direction",
searchPlaceholder: "Search weight limit rules...",
columns: [
{ id: "containerTypeId", header: "Container", accessorKey: "containerTypeId" },
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
{ id: "effectiveTo", header: "To", accessorKey: "effectiveTo", format: "date" },
],
formFields: [
{ name: "containerTypeId", label: "Container type ID", type: "text", required: true },
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: TRADE_DIRECTIONS,
},
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
},
{
slug: "yards",
label: "Yards",
category: "configuration",
subtitle: "Terminal and yard locations",
searchPlaceholder: "Search yards...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "country", header: "Country", accessorKey: "country" },
{ id: "displayOrder", header: "Order", accessorKey: "displayOrder", format: "number" },
activeColumn,
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{ name: "isActive", label: "Active", type: "boolean" },
{ name: "displayOrder", label: "Display order", type: "number" },
],
},
{
slug: "shipping-lines",
label: "Shipping Lines",
category: "configuration",
subtitle: "Shipping line codes and pricing mappings",
searchPlaceholder: "Search shipping lines...",
columns: [
codeColumn("code"),
{ id: "label", header: "Label", accessorKey: "label" },
{ id: "mappedToCode", header: "Mapped to", accessorKey: "mappedToCode" },
{
id: "showExtraFeeNotice",
header: "Extra fee notice",
accessorKey: "showExtraFeeNotice",
format: "boolean",
},
activeColumn,
],
formFields: [
{ name: "code", label: "Code", type: "text", required: true },
{ name: "label", label: "Label", type: "text", required: true },
{ name: "mappedToCode", label: "Mapped to code", type: "text" },
{ name: "showExtraFeeNotice", label: "Show extra fee notice", type: "boolean" },
{ name: "isActive", label: "Active", type: "boolean" },
],
},
{
slug: "rates",
label: "Rates",
category: "rules",
cardTitleKey: "rateType",
cardSubtitleKey: "currency",
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
columns: [
{ id: "rateType", header: "Type", accessorKey: "rateType", format: "code" },
{ id: "currency", header: "Currency", accessorKey: "currency" },
{ id: "rateValue", header: "Value", accessorKey: "rateValue", format: "number" },
{ id: "rateUnit", header: "Unit", accessorKey: "rateUnit" },
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
{ id: "effectiveFrom", header: "From", accessorKey: "effectiveFrom", format: "date" },
],
formFields: [
{ name: "rateType", label: "Rate type", type: "select", required: true, options: RATE_TYPES },
{
name: "containerTypeId",
label: "Container type",
type: "select",
optional: true,
placeholder: "Select container type (optional)",
},
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
options: TRADE_DIRECTIONS,
},
{ name: "currency", label: "Currency", type: "select", required: true, options: CURRENCIES },
{ name: "rateValue", label: "Rate value", type: "number", required: true },
{ name: "rateUnit", label: "Rate unit", type: "select", required: true, options: RATE_UNITS },
{ name: "proposedByStaffId", label: "Proposed by (staff ID)", type: "text", required: true },
{ name: "effectiveFrom", label: "Effective from", type: "date", required: true },
{ name: "effectiveTo", label: "Effective to", type: "date" },
],
},
{
slug: "approval-rules",
label: "Approval Rules",
category: "rules",
cardTitleKey: "actionLabel",
cardSubtitleKey: "requiredRole",
subtitle: "Multi-step booking approval chain",
searchPlaceholder: "Search approval rules...",
columns: [
{
id: "requiresDirectorApproval",
header: "Director chain",
accessorKey: "requiresDirectorApproval",
format: "boolean",
},
{ id: "stepOrder", header: "Step", accessorKey: "stepOrder", format: "number" },
{ id: "requiredRole", header: "Role", accessorKey: "requiredRole" },
{ id: "actionLabel", header: "Action", accessorKey: "actionLabel" },
{ id: "blocksRole", header: "Blocks", accessorKey: "blocksRole" },
],
formFields: [
{ name: "requiresDirectorApproval", label: "Requires director approval chain", type: "boolean" },
{ name: "stepOrder", label: "Step order", type: "number", required: true },
{
name: "requiredRole",
label: "Required role",
type: "select",
required: true,
options: APPROVAL_ROLES,
},
{ name: "actionLabel", label: "Action label", type: "text", required: true },
{
name: "blocksRole",
label: "Blocks role",
type: "select",
optional: true,
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }, ...APPROVAL_ROLES],
},
],
},
];
export const RULE_ENGINE_RESOURCE_MAP = Object.fromEntries(
RULE_ENGINE_RESOURCES.map((r) => [r.slug, r]),
) as Record<RuleEngineResourceSlug, RuleEngineResourceConfig>;
export const getRuleEngineResource = (slug: string): RuleEngineResourceConfig | undefined =>
RULE_ENGINE_RESOURCE_MAP[slug as RuleEngineResourceSlug];
export const ruleEngineResourcePath = (slug: RuleEngineResourceSlug): string => {
const resource = RULE_ENGINE_RESOURCE_MAP[slug];
return `${RULE_ENGINE_CATEGORY_BASE_PATH[resource.category]}/${slug}`;
};
export const getCategorySidebarChildren = (
category: RuleEngineNavCategory,
): SidebarItem[] =>
RULE_ENGINE_RESOURCES.filter((r) => r.category === category).map((r) => ({
label: r.label,
href: ruleEngineResourcePath(r.slug),
}));
export const DEFAULT_CONFIGURATION_SLUG: RuleEngineResourceSlug = "cargo-types";
export const DEFAULT_RULES_SLUG: RuleEngineResourceSlug = "priority-rules";
/** @deprecated Use DEFAULT_CONFIGURATION_SLUG */
export const DEFAULT_RULE_ENGINE_SLUG = DEFAULT_CONFIGURATION_SLUG;