mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 00:38:11 +00:00
Merge freight/develop
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user