mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/backoffice/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -23,6 +23,7 @@ import {
|
||||
Users,
|
||||
Wallet,
|
||||
} from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
Navigate,
|
||||
Outlet,
|
||||
@@ -139,17 +140,17 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
icon: <LayoutDashboard />,
|
||||
},
|
||||
{
|
||||
label: "User Management",
|
||||
label: "Staff",
|
||||
href: "/um",
|
||||
icon: <Users />,
|
||||
},
|
||||
{
|
||||
label: "Booking requests",
|
||||
label: "Bookings",
|
||||
href: "/dashboard/booking-requests",
|
||||
icon: <FileText />,
|
||||
},
|
||||
{
|
||||
label: "Contract requests",
|
||||
label: "Contracts",
|
||||
href: "/dashboard/contract-requests",
|
||||
icon: <FileSignature />,
|
||||
permission: FREIGHT_PERMS.contracts.view,
|
||||
@@ -178,7 +179,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Operations",
|
||||
items: [
|
||||
{
|
||||
label: "Document Clearance",
|
||||
label: "Clearance",
|
||||
href: "/dashboard/contracts/clearance",
|
||||
icon: <ShieldCheck />,
|
||||
permission: [
|
||||
@@ -340,7 +341,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
title: "Port & Terminal",
|
||||
items: [
|
||||
{
|
||||
label: "Import Operations",
|
||||
label: "Imports",
|
||||
href: "/dashboard/import-warehouse",
|
||||
icon: <PackageOpen />,
|
||||
children: [
|
||||
@@ -372,7 +373,7 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Export Operations",
|
||||
label: "Exports",
|
||||
href: "/dashboard/export-warehouse",
|
||||
icon: <Truck />,
|
||||
children: [
|
||||
@@ -501,7 +502,8 @@ const isClearanceItem = (item: SidebarItem): boolean =>
|
||||
/**
|
||||
* Keep only items the user is permitted to see; drop now-empty sections.
|
||||
*
|
||||
* Position-scoped visibility (super_admin bypasses all of this):
|
||||
* Position-scoped visibility (super_admin sees everything):
|
||||
* - Super Admin → sees all items (all permissions pass, all tabs visible)
|
||||
* - Ethiopian GL → sees ONLY the ET document-clearance page.
|
||||
* - Djibouti GL → sees ONLY the DJ clearance page.
|
||||
* - Everyone else → sees everything they have permission for, EXCEPT the two
|
||||
@@ -511,9 +513,11 @@ const filterSidebarByPermission = (
|
||||
sections: SidebarSection[],
|
||||
user: ReturnType<typeof useAuth>["user"],
|
||||
): SidebarSection[] => {
|
||||
const superAdmin = isSuperAdmin(user);
|
||||
const etGl = !superAdmin && isEthiopianGl(user);
|
||||
const djGl = !superAdmin && isDjiboutiGl(user);
|
||||
// Superadmin sees every section and item — no permission filtering.
|
||||
if (isSuperAdmin(user)) return sections;
|
||||
|
||||
const etGl = isEthiopianGl(user);
|
||||
const djGl = isDjiboutiGl(user);
|
||||
|
||||
const permissionAllowed = (item: SidebarItem): boolean => {
|
||||
if (!item.permission) return true;
|
||||
@@ -524,8 +528,6 @@ const filterSidebarByPermission = (
|
||||
};
|
||||
|
||||
const itemAllowed = (item: SidebarItem): boolean => {
|
||||
if (superAdmin) return true;
|
||||
|
||||
// GL positions are locked to their single clearance page.
|
||||
if (etGl) return isEtClearanceItem(item);
|
||||
if (djGl) return isDjClearanceItem(item);
|
||||
@@ -544,6 +546,38 @@ const filterSidebarByPermission = (
|
||||
.filter((section) => section.items.length > 0);
|
||||
};
|
||||
|
||||
const APP_TITLE = "EDR Freight Backoffice";
|
||||
|
||||
/** Flatten sidebar sections (incl. nested children) into {href, label} pairs. */
|
||||
const flattenSidebarItems = (
|
||||
sections: SidebarSection[],
|
||||
): { href: string; label: string }[] =>
|
||||
sections.flatMap((section) =>
|
||||
section.items.flatMap((item) => [
|
||||
...(item.href ? [{ href: item.href, label: item.label }] : []),
|
||||
...(item.children ?? [])
|
||||
.filter((child): child is SidebarItem & { href: string } =>
|
||||
Boolean(child.href),
|
||||
)
|
||||
.map((child) => ({ href: child.href, label: child.label })),
|
||||
]),
|
||||
);
|
||||
|
||||
/** Find the sidebar label whose href matches (exactly or as a prefix of) the current path. */
|
||||
const findActiveSidebarLabel = (
|
||||
pathname: string,
|
||||
sections: SidebarSection[],
|
||||
): string | undefined => {
|
||||
const path = pathname.toLowerCase();
|
||||
const candidates = flattenSidebarItems(sections)
|
||||
.map(({ href, label }) => ({ label, href: href.split("?")[0].toLowerCase() }))
|
||||
.sort((a, b) => b.href.length - a.href.length);
|
||||
|
||||
return candidates.find(
|
||||
({ href }) => path === href || path.startsWith(`${href}/`),
|
||||
)?.label;
|
||||
};
|
||||
|
||||
const DashboardShell = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
@@ -569,6 +603,14 @@ const DashboardShell = () => {
|
||||
: null
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
const activeLabel = findActiveSidebarLabel(
|
||||
location.pathname,
|
||||
sidebarSections,
|
||||
);
|
||||
document.title = activeLabel ? `${activeLabel} | ${APP_TITLE}` : APP_TITLE;
|
||||
}, [location.pathname, sidebarSections]);
|
||||
|
||||
if (glClearanceHome && !location.pathname.startsWith(glClearanceHome)) {
|
||||
return <Navigate to={glClearanceHome} replace />;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ interface AuthEmployeePosition {
|
||||
isDelegate?: boolean;
|
||||
parentPositionId?: string | null;
|
||||
permissions?: AuthPermission[];
|
||||
/** Some IAM payloads nest the position record instead of flattening its key. */
|
||||
position?: { id?: string; key?: string; name?: LocaleText };
|
||||
}
|
||||
|
||||
interface AuthEmployeeRecord {
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
|
||||
const EDR_IMAGE = "/assets/edr_image.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
/** Muted deep-green brand wash for the left panel. */
|
||||
const LEFT_PANEL_BG =
|
||||
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||
|
||||
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||
const IMAGE_FADE_MASK =
|
||||
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Headline shown in the top-left of the green panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
|
||||
const LeftPanel = ({
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<Box
|
||||
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||
style={{ background: LEFT_PANEL_BG }}
|
||||
>
|
||||
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||
<Stack
|
||||
gap="xl"
|
||||
className="relative z-10 p-8 lg:p-10"
|
||||
style={{ maxWidth: 520 }}
|
||||
>
|
||||
<Image
|
||||
src={EDR_LOGO}
|
||||
alt="EDR Freight"
|
||||
h={40}
|
||||
w="auto"
|
||||
fit="contain"
|
||||
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||
/>
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title
|
||||
order={1}
|
||||
c="white"
|
||||
fz={38}
|
||||
fw={800}
|
||||
lh={1.08}
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||
</Title>
|
||||
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||
{taglineBody ??
|
||||
"Manage bookings, track cargo, and run day-to-day logistics for the Ethio–Djibouti Railway from a single backoffice."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||
<Image
|
||||
src={EDR_IMAGE}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fit="cover"
|
||||
pos="absolute"
|
||||
right={0}
|
||||
bottom={0}
|
||||
w="80%"
|
||||
h="60%"
|
||||
style={{
|
||||
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||
maskImage: IMAGE_FADE_MASK,
|
||||
pointerEvents: "none",
|
||||
opacity: 0.9,
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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="auth-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(#auth-grid)" />
|
||||
</svg>
|
||||
</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>
|
||||
);
|
||||
|
||||
export default function AuthShell({
|
||||
children,
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: AuthShellProps) {
|
||||
return (
|
||||
<div className="flex h-[100dvh] overflow-hidden bg-[#e8eaef] px-4 py-3 antialiased sm:px-6 sm:py-4 md:px-[70px]">
|
||||
<div className="flex h-full min-h-0 w-full flex-col gap-3 lg:flex-row lg:gap-4">
|
||||
<LeftPanel tagline={tagline} taglineBody={taglineBody} />
|
||||
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-2xl bg-white 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">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { DateInput, DateTimePicker } from "@mantine/dates";
|
||||
import { DateInput } from "@mantine/dates";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -53,8 +53,9 @@ import { bookingsService } from "@/services/bookings.service";
|
||||
/**
|
||||
* Export customs flow, ordered per the stakeholder process:
|
||||
* customer docs → RO (DJ) → declaration (ET, auto-releases) → create booking (ET)
|
||||
* → payment + wagons → transport document (ET) → train to Djibouti → gate pass (DJ)
|
||||
* → accept T1 (DJ) → final invoice (DJ) + customer slip + GL confirm.
|
||||
* → payment + wagons → transport document / T1 (ET) → train to Djibouti
|
||||
* → accept T1 (DJ, one button after arrival) → gate pass (DJ)
|
||||
* → final invoice (DJ) + customer slip + GL confirm.
|
||||
*/
|
||||
export function computeExportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
@@ -77,8 +78,8 @@ export function computeExportActiveStep(
|
||||
}
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "EXPORT_TRANSPORT_ISSUED")) return 5;
|
||||
if (!clearance.train?.arrivedAt) return 6;
|
||||
if (!clearance.gatepassGranted) return 7;
|
||||
if (!clearance.t1Closed) return 8;
|
||||
if (!clearance.t1Closed) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (clearance.finalInvoice?.status !== "PAID") return 9;
|
||||
return 10;
|
||||
}
|
||||
@@ -395,22 +396,9 @@ export function ExportClearanceStepper({
|
||||
</Stack>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="GL Djibouti grants after arrival"
|
||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||
>
|
||||
<GatepassStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showDj && canDj}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Accept T1"
|
||||
description="GL Djibouti closes the transport document"
|
||||
description="GL Djibouti closes once the train arrives"
|
||||
icon={clearance.t1Closed ? <CheckCircle2 size={14} /> : <PackageCheck size={14} />}
|
||||
>
|
||||
<AcceptT1Step
|
||||
@@ -425,6 +413,14 @@ export function ExportClearanceStepper({
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="Secured on the train schedule after arrival"
|
||||
icon={clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />}
|
||||
>
|
||||
<GatepassStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Final invoice & payment"
|
||||
description="GL Djibouti invoices after offload; customer pays"
|
||||
@@ -491,27 +487,19 @@ function ConfirmExportReleaseFallback({
|
||||
);
|
||||
}
|
||||
|
||||
function GatepassStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [at, setAt] = useState<Date | null>(new Date());
|
||||
const [loading, setLoading] = useState(false);
|
||||
/**
|
||||
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||
*/
|
||||
function GatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||
|
||||
if (clearance.gatepassGranted) {
|
||||
return (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`Gate pass granted${
|
||||
doneLabel={`Gate pass secured${
|
||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
@@ -526,68 +514,21 @@ function GatepassStep({
|
||||
done={false}
|
||||
pendingLabel={
|
||||
arrived
|
||||
? "Train arrived — GL Djibouti can grant the gate pass."
|
||||
? "Train arrived — secure the gate pass on the train schedule."
|
||||
: "Available once the train arrives at Djibouti."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
{canAct && bookingId ? (
|
||||
<>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={16} />}
|
||||
disabled={!arrived}
|
||||
onClick={() => {
|
||||
setAt(new Date());
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
Grant gate pass
|
||||
</Button>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={<Text fw={700}>Grant gate pass</Text>}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DateTimePicker
|
||||
label="Gate pass time"
|
||||
value={at}
|
||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.grantGatepass(
|
||||
bookingId,
|
||||
(at ?? new Date()).toISOString(),
|
||||
);
|
||||
toast.success("Gate pass granted");
|
||||
setOpened(false);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
{scheduleId ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={16} />}
|
||||
>
|
||||
Secure gate pass on train schedule
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
@@ -614,6 +555,7 @@ function AcceptT1Step({
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const files = exportTransitFilesFromWorkflow(workflowFiles);
|
||||
const arrived = Boolean(clearance.train?.arrivedAt);
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -642,9 +584,9 @@ function AcceptT1Step({
|
||||
pendingLabel={
|
||||
!transportIssued
|
||||
? "Waiting for the transport document."
|
||||
: clearance.gatepassGranted
|
||||
? "Gate pass granted — GL Djibouti accepts (closes) the T1."
|
||||
: "Available after the gate pass is granted."
|
||||
: arrived
|
||||
? "Train arrived — GL Djibouti accepts (closes) the T1."
|
||||
: "Available once the train arrives at Djibouti."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
@@ -652,7 +594,7 @@ function AcceptT1Step({
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
disabled={!transportIssued || !clearance.gatepassGranted}
|
||||
disabled={!transportIssued || !arrived}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
@@ -120,7 +120,7 @@ export function GlClearanceUploadModal({
|
||||
/>
|
||||
) : (
|
||||
<DateInput
|
||||
label="Vessel departure date (optional)"
|
||||
label="Vessel arrival date (optional)"
|
||||
value={vesselDate}
|
||||
onChange={(v) => setVesselDate(v ? new Date(v) : null)}
|
||||
size="sm"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
@@ -82,12 +84,13 @@ interface UnitDraft {
|
||||
containerNumber: string;
|
||||
sealNumber: string;
|
||||
vgmTons: number | string;
|
||||
/** Per-unit flags — the line's hazardous/reefer counts are derived from these. */
|
||||
hazardous: boolean;
|
||||
reefer: boolean;
|
||||
}
|
||||
|
||||
interface ContainerLineDraft {
|
||||
containerSize: string;
|
||||
hazardousQuantity: number | string;
|
||||
reeferQuantity: number | string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
@@ -100,7 +103,13 @@ interface BulkLineDraft {
|
||||
}
|
||||
|
||||
function emptyUnit(): UnitDraft {
|
||||
return { containerNumber: "", sealNumber: "", vgmTons: "" };
|
||||
return {
|
||||
containerNumber: "",
|
||||
sealNumber: "",
|
||||
vgmTons: "",
|
||||
hazardous: false,
|
||||
reefer: false,
|
||||
};
|
||||
}
|
||||
|
||||
function bulkUnitOfMeasure(
|
||||
@@ -199,11 +208,13 @@ export default function GlCreateBookingForm() {
|
||||
setContainerLines(
|
||||
lines.containers.map((c) => ({
|
||||
containerSize: c.containerSize,
|
||||
hazardousQuantity: c.hazardousQuantity ?? "0",
|
||||
reeferQuantity: c.reeferQuantity ?? "",
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, () =>
|
||||
emptyUnit(),
|
||||
),
|
||||
// The request carries counts; pre-toggle the first N units so GL sees
|
||||
// the customer's declared hazardous/reefer split and can adjust it.
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, (_, i) => ({
|
||||
...emptyUnit(),
|
||||
hazardous: i < Number(c.hazardousQuantity ?? 0),
|
||||
reefer: i < Number(c.reeferQuantity ?? 0),
|
||||
})),
|
||||
})),
|
||||
);
|
||||
} else if (lines.bulk) {
|
||||
@@ -229,8 +240,6 @@ export default function GlCreateBookingForm() {
|
||||
setContainerLines(
|
||||
containerSizes.map((size) => ({
|
||||
containerSize: size,
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
units: [emptyUnit()],
|
||||
})),
|
||||
);
|
||||
@@ -261,8 +270,8 @@ export default function GlCreateBookingForm() {
|
||||
containers: containerLines.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0),
|
||||
reeferQuantity: Number(l.reeferQuantity || 0),
|
||||
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
||||
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
||||
})),
|
||||
bulkQuantity: bulkLines.reduce(
|
||||
(s, l) => s + Number(l.cargoWeightTons || l.itemCount || 0),
|
||||
@@ -384,12 +393,10 @@ export default function GlCreateBookingForm() {
|
||||
.map((l) => ({
|
||||
containerSize: l.containerSize,
|
||||
quantity: l.units.length,
|
||||
...(l.hazardousQuantity !== ""
|
||||
? { hazardousQuantity: Number(l.hazardousQuantity) }
|
||||
: {}),
|
||||
...(l.reeferQuantity !== ""
|
||||
? { reeferQuantity: Number(l.reeferQuantity) }
|
||||
: {}),
|
||||
// Counts are derived from the per-unit toggles — they can never
|
||||
// exceed the line quantity.
|
||||
hazardousQuantity: l.units.filter((u) => u.hazardous).length,
|
||||
reeferQuantity: l.units.filter((u) => u.reefer).length,
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
@@ -652,7 +659,7 @@ export default function GlCreateBookingForm() {
|
||||
<Text fz={14} fw={700} mb={10}>
|
||||
{line.containerSize} containers
|
||||
</Text>
|
||||
<Group gap={12} grow mb={12} align="flex-start">
|
||||
<Group gap={12} mb={12} align="flex-start">
|
||||
<NumberInput
|
||||
label="Quantity *"
|
||||
min={1}
|
||||
@@ -660,37 +667,24 @@ export default function GlCreateBookingForm() {
|
||||
onChange={(v) => syncUnits(lineIdx, Number(v) || 0)}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
w={160}
|
||||
/>
|
||||
{contract.isHazardous ? (
|
||||
<NumberInput
|
||||
label="Hazardous qty"
|
||||
min={0}
|
||||
value={line.hazardousQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { hazardousQuantity: v })
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
<Badge variant="light" color="red" radius="sm" mt={30}>
|
||||
{line.units.filter((u) => u.hazardous).length} hazardous
|
||||
</Badge>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<NumberInput
|
||||
label="Reefer qty"
|
||||
min={0}
|
||||
value={line.reeferQuantity}
|
||||
onChange={(v) =>
|
||||
patchLine(lineIdx, { reeferQuantity: v })
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
<Badge variant="light" color="blue" radius="sm" mt={30}>
|
||||
{line.units.filter((u) => u.reefer).length} refrigerated
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
<Stack gap={10} mt={8}>
|
||||
{line.units.map((unit, unitIdx) => (
|
||||
<Group key={unitIdx} gap={10} grow align="flex-start">
|
||||
<Group key={unitIdx} gap={10} align="flex-start" wrap="nowrap">
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Container number *" : undefined}
|
||||
placeholder="e.g. MSCU1234567"
|
||||
@@ -702,6 +696,7 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label={unitIdx === 0 ? "Seal number" : undefined}
|
||||
@@ -714,6 +709,7 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<NumberInput
|
||||
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
|
||||
@@ -726,7 +722,52 @@ export default function GlCreateBookingForm() {
|
||||
}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
{/* Per-unit flags: toggle exactly the containers that are
|
||||
hazardous / refrigerated; line counts derive from these. */}
|
||||
{contract.isHazardous ? (
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
{unitIdx === 0 ? (
|
||||
<Text fz={12} fw={600} c="#4A5A68">
|
||||
Hazardous
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
color="red"
|
||||
size="sm"
|
||||
mt={unitIdx === 0 ? 0 : 8}
|
||||
aria-label={`Container ${unitIdx + 1} hazardous`}
|
||||
checked={unit.hazardous}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
hazardous: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
{contract.isReefer ? (
|
||||
<Stack gap={6} align="center" style={{ flexShrink: 0 }}>
|
||||
{unitIdx === 0 ? (
|
||||
<Text fz={12} fw={600} c="#4A5A68">
|
||||
Reefer
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
color="blue"
|
||||
size="sm"
|
||||
mt={unitIdx === 0 ? 0 : 8}
|
||||
aria-label={`Container ${unitIdx + 1} refrigerated`}
|
||||
checked={unit.reefer}
|
||||
onChange={(e) =>
|
||||
patchUnit(lineIdx, unitIdx, {
|
||||
reefer: e.currentTarget.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Group>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -235,6 +235,8 @@ export function GlUpcomingWindowsSection() {
|
||||
const rows = (data ?? []).filter(
|
||||
(w) => w.windowPhase != null && w.windowPhase !== "DONE" && !isPast(w),
|
||||
);
|
||||
// Canceled schedules are retired to windowPhase='DONE' server-side, so the
|
||||
// guard above already excludes them; they never reach the upcoming list.
|
||||
// Open lanes first, then by opening time.
|
||||
return rows.sort((a, b) => {
|
||||
const openDiff = Number(b.isOpenNow) - Number(a.isOpenNow);
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { PhasedFileDropzone, PhasedMultiFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||
import {
|
||||
TransitPermitMultiUpload,
|
||||
@@ -100,6 +98,7 @@ function computeImportActiveStep(
|
||||
clearance: ClearanceViewLike,
|
||||
bookingCreated: boolean,
|
||||
bookingMilestones: MilestoneRow[],
|
||||
t1Uploaded: boolean,
|
||||
): number {
|
||||
if (!isMilestoneDone(clearance.milestones, "DOCUMENTS_APPROVED")) return 0;
|
||||
if (!isMilestoneDone(clearance.milestones, "DECLARED")) return 1;
|
||||
@@ -121,16 +120,24 @@ function computeImportActiveStep(
|
||||
if (!isMilestoneDone(clearance.milestones, "DO_COLLECTED")) return 6;
|
||||
if (!bookingCreated) return 7;
|
||||
if (!clearance.gatepassGranted) return 8;
|
||||
if (!clearance.t1?.closed) return 9;
|
||||
if (!isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")) return 10;
|
||||
if (!t1Uploaded && !clearance.t1?.closed) return 9;
|
||||
if (!clearance.t1?.closed) return 10;
|
||||
// Risk is "assigned" when the booking milestone says so OR the clearance view
|
||||
// already carries a riskLevel. The ET page derives its bookingMilestones from a
|
||||
// separately-fetched booking id that can lag or mismatch the booking carrying
|
||||
// the milestone — `clearance.riskLevel` is server truth and matches the badge.
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
if (!riskAssigned) return 11;
|
||||
// Additional duty round is optional — resolved once skipped or paid.
|
||||
const secondDutyResolved =
|
||||
clearance.secondDuty?.skipped ||
|
||||
clearance.secondDuty?.paid ||
|
||||
isBookingMilestoneDone(bookingMilestones, "SECOND_DUTY_PAID");
|
||||
if (!secondDutyResolved) return 11;
|
||||
if (!clearance.importReleaseGranted) return 12;
|
||||
return 13;
|
||||
if (!secondDutyResolved) return 12;
|
||||
if (!clearance.importReleaseGranted) return 13;
|
||||
return 14;
|
||||
}
|
||||
|
||||
function t1FilesFromWorkflow(
|
||||
@@ -228,12 +235,25 @@ export function PhasedClearanceActionPanel({
|
||||
// The booking that carries the post-booking steps (gate pass, risk, duty, release).
|
||||
const actionBookingId =
|
||||
clearance.t1?.bookingId ?? clearance.linkedBookingId ?? bookingId ?? null;
|
||||
const t1Uploaded = t1FilesFromWorkflow(workflowFiles).length > 0;
|
||||
// Risk is assigned when the clearance view carries a riskLevel (server truth,
|
||||
// drives the badge) OR the fetched booking milestone confirms it. Kept in sync
|
||||
// with computeImportActiveStep so the stepper never freezes on a page whose
|
||||
// bookingMilestones lag/mismatch the booking that holds the milestone.
|
||||
const riskAssigned =
|
||||
Boolean(clearance.riskLevel) ||
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED");
|
||||
const activeStep = useMemo(
|
||||
() =>
|
||||
isImport
|
||||
? computeImportActiveStep(clearance, effectiveBookingCreated, bookingMilestones)
|
||||
? computeImportActiveStep(
|
||||
clearance,
|
||||
effectiveBookingCreated,
|
||||
bookingMilestones,
|
||||
t1Uploaded,
|
||||
)
|
||||
: 0,
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones],
|
||||
[clearance, isImport, effectiveBookingCreated, bookingMilestones, t1Uploaded],
|
||||
);
|
||||
|
||||
if (isImport) {
|
||||
@@ -536,42 +556,60 @@ export function PhasedClearanceActionPanel({
|
||||
|
||||
<Stepper.Step
|
||||
label="Gate pass"
|
||||
description="GL Djibouti grants after wagon allocation"
|
||||
description="Secured on the train schedule after wagon allocation"
|
||||
icon={
|
||||
clearance.gatepassGranted ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
}
|
||||
>
|
||||
<ImportGatepassStep
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showDj && canDj}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
<ImportGatepassStep clearance={clearance} />
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="T1 transport documents"
|
||||
description="GL Djibouti uploads after wagon allocation; GL Ethiopia closes on arrival"
|
||||
description="GL Djibouti uploads after the gate pass is secured"
|
||||
icon={
|
||||
clearance.t1?.closed ? <CheckCircle2 size={14} /> : <Truck size={14} />
|
||||
t1Uploaded || clearance.t1?.closed ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<Truck size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ImportT1Section
|
||||
<ImportT1UploadStep
|
||||
t1={clearance.t1 ?? null}
|
||||
gatepassGranted={Boolean(clearance.gatepassGranted)}
|
||||
workflowFiles={workflowFiles}
|
||||
canDjAct={showDj && canDj}
|
||||
canEtAct={showEt && canEt}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Close T1"
|
||||
description="GL Ethiopia closes once the train arrives"
|
||||
icon={
|
||||
clearance.t1?.closed ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<PackageCheck size={14} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<ImportT1CloseStep
|
||||
t1={clearance.t1 ?? null}
|
||||
t1Uploaded={t1Uploaded}
|
||||
canEtAct={showEt && canEt}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
|
||||
<Stepper.Step
|
||||
label="Customs risk"
|
||||
description="GL Ethiopia assigns Green / Yellow / Red"
|
||||
icon={
|
||||
isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED") ? (
|
||||
riskAssigned ? (
|
||||
<CheckCircle2 size={14} />
|
||||
) : (
|
||||
<ShieldAlert size={14} />
|
||||
@@ -582,7 +620,7 @@ export function PhasedClearanceActionPanel({
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
done={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
done={riskAssigned}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
@@ -596,7 +634,7 @@ export function PhasedClearanceActionPanel({
|
||||
bookingId={actionBookingId}
|
||||
clearance={clearance}
|
||||
canAct={showEt && canEt}
|
||||
riskAssigned={isBookingMilestoneDone(bookingMilestones, "RISK_ASSIGNED")}
|
||||
riskAssigned={riskAssigned}
|
||||
onChanged={onChanged}
|
||||
onViewFile={onViewFile}
|
||||
onDownloadFile={onDownloadFile}
|
||||
@@ -652,26 +690,29 @@ export function PhasedClearanceActionPanel({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportT1Section({
|
||||
/**
|
||||
* GL Djibouti uploads the T1 transport documents (multi-file) once the gate
|
||||
* pass is secured on the train schedule; locked on departure or T1 close.
|
||||
*/
|
||||
function ImportT1UploadStep({
|
||||
t1,
|
||||
gatepassGranted,
|
||||
workflowFiles = [],
|
||||
canDjAct,
|
||||
canEtAct,
|
||||
onChanged,
|
||||
onViewFile,
|
||||
onDownloadFile,
|
||||
}: {
|
||||
t1: Freight.ClearanceT1State | null;
|
||||
gatepassGranted: boolean;
|
||||
workflowFiles?: Freight.ClearanceWorkflowFile[];
|
||||
canDjAct: boolean;
|
||||
canEtAct: boolean;
|
||||
onChanged?: () => void;
|
||||
onViewFile?: (file: { name: string; url: string }) => void;
|
||||
onDownloadFile?: (file: { id: string; name: string }) => void;
|
||||
}) {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
const uploaded = t1FilesFromWorkflow(workflowFiles);
|
||||
const replaceMode = uploaded.length > 0;
|
||||
@@ -687,8 +728,8 @@ function ImportT1Section({
|
||||
}
|
||||
|
||||
const departed = Boolean(t1.trainDepartedAt);
|
||||
const arrived = Boolean(t1.trainArrivedAt);
|
||||
const canUpload = canDjAct && t1.wagonAllocated && !departed && !t1.closed;
|
||||
const canUpload =
|
||||
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
@@ -714,7 +755,7 @@ function ImportT1Section({
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel="T1 accepted and closed by GL Ethiopia — documents are final."
|
||||
doneLabel="T1 documents are final — closed by GL Ethiopia."
|
||||
/>
|
||||
) : !t1.wagonAllocated ? (
|
||||
<StepStatus
|
||||
@@ -722,6 +763,12 @@ function ImportT1Section({
|
||||
pendingLabel="Waiting for operations to allocate wagons."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : !gatepassGranted ? (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : departed ? (
|
||||
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
The train has departed — T1 documents are locked and can no longer be changed.
|
||||
@@ -732,6 +779,8 @@ function ImportT1Section({
|
||||
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
|
||||
doneLabel=""
|
||||
/>
|
||||
) : uploaded.length > 0 && !canUpload ? (
|
||||
<StepStatus done pendingLabel="" doneLabel="T1 documents uploaded." />
|
||||
) : null}
|
||||
|
||||
{canUpload ? (
|
||||
@@ -778,66 +827,120 @@ function ImportT1Section({
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{canEtAct && !t1.closed ? (
|
||||
arrived ? (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train has arrived — review the T1 documents and close (accept) them.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={closing}
|
||||
disabled={uploaded.length === 0}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await contractsService.closeT1(t1.bookingId);
|
||||
toast.success("T1 closed");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
) : departed ? (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
Train en route — T1 can be closed once it arrives in Ethiopia.
|
||||
</Alert>
|
||||
) : null
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** GL DJ grants the import gate pass once wagons are allocated (captures time). */
|
||||
function ImportGatepassStep({
|
||||
bookingId,
|
||||
clearance,
|
||||
canAct,
|
||||
/**
|
||||
* GL Ethiopia closes (accepts) the T1 set with one click once the train has
|
||||
* arrived. Separate step from the GL Djibouti upload.
|
||||
*/
|
||||
function ImportT1CloseStep({
|
||||
t1,
|
||||
t1Uploaded,
|
||||
canEtAct,
|
||||
onChanged,
|
||||
}: {
|
||||
bookingId: string | null;
|
||||
clearance: ClearanceViewLike;
|
||||
canAct: boolean;
|
||||
t1: Freight.ClearanceT1State | null;
|
||||
t1Uploaded: boolean;
|
||||
canEtAct: boolean;
|
||||
onChanged?: () => void;
|
||||
}) {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [at, setAt] = useState<Date | null>(new Date());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
|
||||
if (!t1) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Available once the shipment booking is created."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (t1.closed) {
|
||||
return (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`T1 accepted and closed by GL Ethiopia${
|
||||
t1.closedAt ? ` · ${new Date(t1.closedAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!t1Uploaded) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Waiting for GL Djibouti to upload T1 transport documents."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!t1.trainArrivedAt) {
|
||||
return (
|
||||
<Alert color="blue" variant="light" icon={<Clock size={16} />}>
|
||||
{t1.trainDepartedAt
|
||||
? "Train en route — T1 can be closed once it arrives in Ethiopia."
|
||||
: "T1 can be closed once the train arrives in Ethiopia."}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (!canEtAct) {
|
||||
return (
|
||||
<StepStatus
|
||||
done={false}
|
||||
pendingLabel="Train arrived — waiting for GL Ethiopia to close the T1."
|
||||
doneLabel=""
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
The train has arrived — review the T1 documents and close (accept) them.
|
||||
</Text>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={closing}
|
||||
leftSection={<PackageCheck size={16} />}
|
||||
onClick={async () => {
|
||||
setClosing(true);
|
||||
try {
|
||||
await contractsService.closeT1(t1.bookingId);
|
||||
toast.success("T1 closed");
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setClosing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Accept & close T1
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate pass status, read-only. Secured on the train schedule's "Save as
|
||||
* Secured" action (train-scheduling-v2) — clearance no longer grants it directly.
|
||||
*/
|
||||
function ImportGatepassStep({ clearance }: { clearance: ClearanceViewLike }) {
|
||||
const scheduleId = clearance.train?.scheduleId ?? null;
|
||||
|
||||
if (clearance.gatepassGranted) {
|
||||
return (
|
||||
<StepStatus
|
||||
done
|
||||
pendingLabel=""
|
||||
doneLabel={`Gate pass granted${
|
||||
doneLabel={`Gate pass secured${
|
||||
clearance.gatepassAt ? ` · ${new Date(clearance.gatepassAt).toLocaleString()}` : ""
|
||||
}`}
|
||||
/>
|
||||
@@ -852,68 +955,21 @@ function ImportGatepassStep({
|
||||
done={false}
|
||||
pendingLabel={
|
||||
wagonAllocated
|
||||
? "Wagons allocated — GL Djibouti can grant the gate pass."
|
||||
? "Wagons allocated — secure the gate pass on the train schedule."
|
||||
: "Available once wagons are allocated."
|
||||
}
|
||||
doneLabel=""
|
||||
/>
|
||||
{canAct && bookingId ? (
|
||||
<>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={16} />}
|
||||
disabled={!wagonAllocated}
|
||||
onClick={() => {
|
||||
setAt(new Date());
|
||||
setOpened(true);
|
||||
}}
|
||||
>
|
||||
Grant gate pass
|
||||
</Button>
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
title={<Text fw={700}>Grant gate pass</Text>}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<DateTimePicker
|
||||
label="Gate pass time"
|
||||
value={at}
|
||||
onChange={(v) => setAt(v ? new Date(v) : null)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setOpened(false)} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await contractsService.grantGatepass(
|
||||
bookingId,
|
||||
(at ?? new Date()).toISOString(),
|
||||
);
|
||||
toast.success("Gate pass granted");
|
||||
setOpened(false);
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</>
|
||||
{scheduleId ? (
|
||||
<Button
|
||||
component="a"
|
||||
href={`/dashboard/operations/train-scheduling-v2/${scheduleId}`}
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={16} />}
|
||||
>
|
||||
Secure gate pass on train schedule
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -5,14 +5,12 @@ import {
|
||||
Burger,
|
||||
Divider,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
Text,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
Languages,
|
||||
@@ -25,6 +23,8 @@ import {
|
||||
import { type ReactNode } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
|
||||
import type { PageMeta } from "./types";
|
||||
|
||||
export interface FreightDashboardHeaderProps {
|
||||
@@ -117,19 +117,7 @@ const FreightDashboardHeader = ({
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Notifications" withArrow openDelay={300}>
|
||||
<Indicator
|
||||
color="edr-accent"
|
||||
size={8}
|
||||
offset={6}
|
||||
withBorder
|
||||
aria-label="Unread notifications"
|
||||
>
|
||||
<UnstyledButton className={ISLAND} aria-label="Notifications">
|
||||
<Bell size={17} strokeWidth={1.8} />
|
||||
</UnstyledButton>
|
||||
</Indicator>
|
||||
</Tooltip>
|
||||
<NotificationBellContainer />
|
||||
|
||||
{enableThemeToggle && (
|
||||
<Tooltip
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "react";
|
||||
|
||||
import type { SidebarItem, SidebarSection } from "./types";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export interface FreightSidebarProps {
|
||||
sections: SidebarSection[];
|
||||
@@ -35,15 +36,15 @@ const BRAND_LOGO = "/assets/logo.svg";
|
||||
const navClassNames = (active: boolean) =>
|
||||
active
|
||||
? {
|
||||
root: "rounded-md transition-all duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
root: "rounded-md transition-all py-1.5! duration-150 bg-edr-soft! ring-1 ring-inset ring-edr-primary/40 [&_svg]:size-[16px]",
|
||||
label: "text-edr-primary-dark! font-medium! text-sm!",
|
||||
section: "text-edr-primary-dark!",
|
||||
}
|
||||
: {
|
||||
root: "rounded-md transition-all duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
root: "rounded-md transition-all py-1.5! duration-150 hover:bg-[#EEF2F6]! [&_svg]:size-4",
|
||||
label: "text-edr-text! font-medium! text-sm! hover:text-edr-ink!",
|
||||
section: "text-edr-text!",
|
||||
};
|
||||
|
||||
const itemKey = (parentKey: string, item: SidebarItem, index: number) =>
|
||||
`${parentKey}/${item.href ?? item.label}/${index}`;
|
||||
@@ -65,7 +66,9 @@ const FreightSidebar = ({
|
||||
const isHrefActive = useCallback(
|
||||
(href: string) => {
|
||||
const normalized = href.toLowerCase();
|
||||
return activePath === normalized || activePath.startsWith(`${normalized}/`);
|
||||
return (
|
||||
activePath === normalized || activePath.startsWith(`${normalized}/`)
|
||||
);
|
||||
},
|
||||
[activePath],
|
||||
);
|
||||
@@ -109,9 +112,7 @@ const FreightSidebar = ({
|
||||
|
||||
if (hasChildren) {
|
||||
const isLink = !!item.href;
|
||||
const active =
|
||||
(isLink ? isHrefActive(item.href!) : false) ||
|
||||
branchActive(item.children!);
|
||||
const active = isLink ? isHrefActive(item.href!) : false;
|
||||
const isOpen = openMap[key] ?? false;
|
||||
|
||||
return (
|
||||
@@ -124,7 +125,7 @@ const FreightSidebar = ({
|
||||
active={active}
|
||||
opened={isOpen}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={ () => toggle(key)}
|
||||
onClick={() => toggle(key)}
|
||||
rightSection={
|
||||
<Box
|
||||
component="span"
|
||||
@@ -140,7 +141,9 @@ const FreightSidebar = ({
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className="text-edr-muted transition-transform duration-200"
|
||||
style={{ transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)" }}
|
||||
style={{
|
||||
transform: isOpen ? "rotate(-180deg)" : "rotate(180deg)",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
}
|
||||
@@ -161,8 +164,9 @@ const FreightSidebar = ({
|
||||
label={item.label}
|
||||
leftSection={item.icon}
|
||||
active={active}
|
||||
component={Link}
|
||||
classNames={navClassNames(active)}
|
||||
onClick={() => onNavigate?.(item.href!)}
|
||||
to={item.href!}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -178,7 +182,7 @@ const FreightSidebar = ({
|
||||
tt="uppercase"
|
||||
px="sm"
|
||||
mb={6}
|
||||
className={ "text-edr-muted!" }
|
||||
className={"text-edr-muted!"}
|
||||
style={{ fontWeight: 500, fontSize: 10, letterSpacing: "0.05em" }}
|
||||
>
|
||||
{section.title}
|
||||
@@ -232,14 +236,24 @@ const FreightSidebar = ({
|
||||
</Box>
|
||||
</Group>
|
||||
{onClose && (
|
||||
<UnstyledButton onClick={onClose} hiddenFrom="sm" aria-label="Close sidebar">
|
||||
<UnstyledButton
|
||||
onClick={onClose}
|
||||
hiddenFrom="sm"
|
||||
aria-label="Close sidebar"
|
||||
>
|
||||
<X size={18} className="text-edr-muted" strokeWidth={1.8} />
|
||||
</UnstyledButton>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Nav */}
|
||||
<AppShell.Section grow component={ScrollArea} type="never" px="sm" pb="md">
|
||||
<AppShell.Section
|
||||
grow
|
||||
component={ScrollArea}
|
||||
type="never"
|
||||
px="sm"
|
||||
pb="md"
|
||||
>
|
||||
<Stack gap="lg">{renderedSections}</Stack>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Select,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import { Clock, Info, Moon, Sun } from "lucide-react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import DurationField from "@/components/trainScheduling/DurationField";
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { UpdateScheduleWindowRulePayload } from "@/types/trainScheduling";
|
||||
|
||||
/** Fallbacks matching the API's global-rules defaults (used when a field is null). */
|
||||
const DEFAULTS = {
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
importWindowLeadDays: 3,
|
||||
};
|
||||
|
||||
/** 12-hour label for an EAT hour 0–23, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
|
||||
function hourLabel(hour: number): string {
|
||||
const period = hour < 12 ? "AM" : "PM";
|
||||
const h12 = hour % 12 === 0 ? 12 : hour % 12;
|
||||
return `${h12}:00 ${period}`;
|
||||
}
|
||||
|
||||
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, h) => ({
|
||||
value: String(h),
|
||||
label: `${hourLabel(h)} · ${String(h).padStart(2, "0")}:00`,
|
||||
}));
|
||||
|
||||
interface FormState {
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number | "";
|
||||
docReviewMinutes: number | "";
|
||||
paymentWindowMinutes: number | "";
|
||||
importWindowLeadDays: number | "";
|
||||
}
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export interface BookingWindowSettingsModalProps {
|
||||
scheduleId: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save (e.g. to refetch a list). */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-schedule booking-window settings editor. Prefills from the schedule's own
|
||||
* rule snapshot, lets staff tune the daily desk hours / durations for just that
|
||||
* train, and saves an override. Only editable before the window opens.
|
||||
*/
|
||||
export default function BookingWindowSettingsModal({
|
||||
scheduleId,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: BookingWindowSettingsModalProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const detailQuery = useQuery({
|
||||
...api.trainScheduling.scheduleDetail.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
}),
|
||||
enabled: opened && Boolean(scheduleId),
|
||||
});
|
||||
const schedule = detailQuery.data;
|
||||
|
||||
const save = useMutation(
|
||||
api.trainScheduling.updateScheduleWindowRule.mutationOptions(),
|
||||
);
|
||||
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
|
||||
// Seed the form from the schedule's snapshot once it loads (or when reopened).
|
||||
useEffect(() => {
|
||||
if (!opened || !schedule) return;
|
||||
const r = schedule.windowRule;
|
||||
setForm({
|
||||
windowOpenHour: r?.windowOpenHour ?? DEFAULTS.windowOpenHour,
|
||||
windowCloseHour: r?.windowCloseHour ?? DEFAULTS.windowCloseHour,
|
||||
windowDurationHours: r?.windowDurationHours ?? DEFAULTS.windowDurationHours,
|
||||
docReviewMinutes: r?.docReviewMinutes ?? DEFAULTS.docReviewMinutes,
|
||||
paymentWindowMinutes:
|
||||
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
|
||||
importWindowLeadDays:
|
||||
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
|
||||
});
|
||||
}, [opened, schedule]);
|
||||
|
||||
const isExport = schedule?.direction === "EXPORT";
|
||||
const canEdit = schedule?.windowPhase === "PRE_WINDOW";
|
||||
const is24h =
|
||||
form != null && form.windowOpenHour === form.windowCloseHour;
|
||||
// Close < open is a valid OVERNIGHT desk (e.g. 08:00 → 07:00 next morning),
|
||||
// not an error — the engine wraps it across midnight.
|
||||
const isOvernight =
|
||||
form != null && form.windowCloseHour < form.windowOpenHour;
|
||||
|
||||
const reopenSummary = useMemo(() => {
|
||||
if (!form) return "";
|
||||
const doc = Number(form.docReviewMinutes) || 0;
|
||||
const pay = Number(form.paymentWindowMinutes) || 0;
|
||||
const total = doc + pay;
|
||||
const h = Math.floor(total / 60);
|
||||
const m = total % 60;
|
||||
const parts = [h ? `${h}h` : "", m ? `${m}m` : ""].filter(Boolean);
|
||||
return parts.length ? parts.join(" ") : "0m";
|
||||
}, [form]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!scheduleId || !form) return;
|
||||
// Numeric fields must hold real values.
|
||||
const duration = Number(form.windowDurationHours);
|
||||
const doc = Number(form.docReviewMinutes);
|
||||
const pay = Number(form.paymentWindowMinutes);
|
||||
const lead = Number(form.importWindowLeadDays);
|
||||
if (
|
||||
form.windowDurationHours === "" ||
|
||||
form.docReviewMinutes === "" ||
|
||||
form.paymentWindowMinutes === "" ||
|
||||
form.importWindowLeadDays === "" ||
|
||||
!Number.isFinite(duration) ||
|
||||
!Number.isFinite(doc) ||
|
||||
!Number.isFinite(pay) ||
|
||||
!Number.isFinite(lead)
|
||||
) {
|
||||
toast({
|
||||
title: "Fill every field before saving",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload: UpdateScheduleWindowRulePayload = {
|
||||
windowOpenHour: form.windowOpenHour,
|
||||
windowCloseHour: form.windowCloseHour,
|
||||
windowDurationHours: duration,
|
||||
docReviewMinutes: doc,
|
||||
paymentWindowMinutes: pay,
|
||||
importWindowLeadDays: lead,
|
||||
};
|
||||
|
||||
try {
|
||||
await save.mutateAsync({ id: scheduleId, payload });
|
||||
toast({ title: "Booking window settings updated" });
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update booking window"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
size="lg"
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<Clock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} lh={1.2}>
|
||||
Booking window settings
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{schedule?.route?.name ?? "This schedule only"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
{detailQuery.isLoading || !form ? (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : !canEdit ? (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="yellow"
|
||||
icon={<Info size={16} />}
|
||||
title="Window already open"
|
||||
>
|
||||
Booking window settings can only be changed before the window opens.
|
||||
This schedule is currently{" "}
|
||||
<b>{String(schedule?.windowPhase ?? "not window-managed")}</b>.
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
{isExport ? (
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
Export schedules use a single FCFS lead window — the daily desk
|
||||
hours below don't apply, only the lead time does.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* ── Daily desk hours ─────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Group justify="space-between" align="center" mb={6}>
|
||||
<Text size="sm" fw={600}>
|
||||
Daily desk hours (EAT)
|
||||
</Text>
|
||||
{is24h ? (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<Moon size={12} />}
|
||||
>
|
||||
24-hour desk
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
leftSection={<Sun size={12} />}
|
||||
>
|
||||
{hourLabel(form.windowOpenHour)} – {hourLabel(form.windowCloseHour)}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Opens"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowOpenHour)}
|
||||
onChange={(v) =>
|
||||
v != null &&
|
||||
setForm((f) => f && { ...f, windowOpenHour: Number(v) })
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Select
|
||||
label="Closes"
|
||||
data={HOUR_OPTIONS}
|
||||
value={String(form.windowCloseHour)}
|
||||
onChange={(v) =>
|
||||
v != null &&
|
||||
setForm((f) => f && { ...f, windowCloseHour: Number(v) })
|
||||
}
|
||||
allowDeselect={false}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
disabled={isExport}
|
||||
/>
|
||||
</Group>
|
||||
{isOvernight && !is24h ? (
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
Overnight desk — opens {form.windowOpenHour}:00 and runs past
|
||||
midnight, closing {form.windowCloseHour}:00 the next morning.
|
||||
</Text>
|
||||
) : null}
|
||||
<Switch
|
||||
mt="sm"
|
||||
size="sm"
|
||||
color="grape"
|
||||
label="Run 24 hours a day (never pause overnight)"
|
||||
checked={is24h}
|
||||
disabled={isExport}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
setForm((f) => {
|
||||
if (!f) return f;
|
||||
// On → close == open (24h desk). Off → restore a normal ~9h
|
||||
// day, always kept ≥ open hour so it never lands invalid.
|
||||
const close = checked
|
||||
? f.windowOpenHour
|
||||
: Math.min(23, f.windowOpenHour + 9);
|
||||
return { ...f, windowCloseHour: close };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
A not-yet-full train pauses at the close hour and resumes the next
|
||||
morning at the open hour, every day until it fills or departs.
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Cycle timing ─────────────────────────────────────────────── */}
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb={6}>
|
||||
Cycle timing
|
||||
</Text>
|
||||
<Stack gap="sm">
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long each booking cycle stays open before it closes for review"
|
||||
value={form.windowDurationHours}
|
||||
nativeUnit="hours"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, windowDurationHours: v })
|
||||
}
|
||||
min={0.0166}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<Group grow align="flex-start">
|
||||
<DurationField
|
||||
label="Document review"
|
||||
description="Staff time to accept documents after the window closes"
|
||||
value={form.docReviewMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, docReviewMinutes: v })
|
||||
}
|
||||
min={0}
|
||||
disabled={isExport}
|
||||
/>
|
||||
<DurationField
|
||||
label="Payment window"
|
||||
description="Time a selected customer has to pay"
|
||||
value={form.paymentWindowMinutes}
|
||||
nativeUnit="minutes"
|
||||
onChange={(v) =>
|
||||
setForm((f) => f && { ...f, paymentWindowMinutes: v })
|
||||
}
|
||||
min={1}
|
||||
disabled={isExport}
|
||||
/>
|
||||
</Group>
|
||||
{!isExport ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Reopen gap after each cycle = document review + payment ={" "}
|
||||
<b>{reopenSummary}</b>.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* ── Lead time ────────────────────────────────────────────────── */}
|
||||
<NumberInput
|
||||
label={isExport ? "Booking lead (days)" : "Window lead (days)"}
|
||||
description={
|
||||
isExport
|
||||
? "How many days before departure export booking opens"
|
||||
: "How many days before departure the booking window starts"
|
||||
}
|
||||
value={form.importWindowLeadDays}
|
||||
onChange={(v) =>
|
||||
setForm(
|
||||
(f) =>
|
||||
f && {
|
||||
...f,
|
||||
importWindowLeadDays: v === "" ? "" : Number(v),
|
||||
},
|
||||
)
|
||||
}
|
||||
min={0}
|
||||
clampBehavior="none"
|
||||
allowDecimal={false}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={save.isPending}>
|
||||
Save settings
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import { CalendarClock, Info } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
function parseError(error: unknown, fallback: string): string {
|
||||
if (isAxiosError(error)) {
|
||||
const message = error.response?.data?.message;
|
||||
if (Array.isArray(message)) return message.join(", ");
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** ISO → the `YYYY-MM-DDTHH:mm` value a datetime-local input expects (local time). */
|
||||
function toLocalInputValue(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||
`T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
export interface EditScheduleDateModalProps {
|
||||
scheduleId: string | null;
|
||||
currentDate: string | null;
|
||||
routeName?: string | null;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
/** Called after a successful save (e.g. to refetch a list). */
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reschedule a train's departure date. Only shown for schedules whose booking
|
||||
* window has not opened yet; the API rejects a date inside the booking lead
|
||||
* window (import/intercity lead in days, export in hours).
|
||||
*/
|
||||
export default function EditScheduleDateModal({
|
||||
scheduleId,
|
||||
currentDate,
|
||||
routeName,
|
||||
opened,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: EditScheduleDateModalProps) {
|
||||
const { toast } = useToast();
|
||||
const save = useMutation(
|
||||
api.trainScheduling.updateScheduleDate.mutationOptions(),
|
||||
);
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setValue(toLocalInputValue(currentDate));
|
||||
}, [opened, currentDate]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!scheduleId || !value) {
|
||||
toast({ title: "Pick a departure date", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await save.mutateAsync({
|
||||
id: scheduleId,
|
||||
scheduleDate: new Date(value).toISOString(),
|
||||
});
|
||||
toast({ title: "Departure date updated" });
|
||||
onSaved?.();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Update failed",
|
||||
description: parseError(err, "Could not update departure date"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap="sm">
|
||||
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
|
||||
<CalendarClock size={18} />
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<Text fw={600} lh={1.2}>
|
||||
Edit departure date
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" lh={1.2}>
|
||||
{routeName ?? "This schedule only"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert variant="light" color="blue" icon={<Info size={16} />}>
|
||||
The date can only be changed before the booking window opens, and must
|
||||
still leave room for the booking lead window before departure.
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Departure date"
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={save.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
CheckCircle2,
|
||||
Inbox,
|
||||
PackageCheck,
|
||||
Repeat,
|
||||
PackageX,
|
||||
// Repeat, // used by the hidden Move (reassign) button
|
||||
Train,
|
||||
Weight,
|
||||
X,
|
||||
@@ -152,6 +153,12 @@ export function ScheduleWorkspacePanel({
|
||||
// ── Mutations (reuse the existing endpoints) ───────────────────────────────
|
||||
const assign = useMutation(api.trainScheduling.assignBookings.mutationOptions());
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const setLoading = useMutation(
|
||||
api.trainScheduling.setLoadingStatus.mutationOptions(),
|
||||
);
|
||||
const confirmLoading = useMutation(
|
||||
api.trainScheduling.confirmLoading.mutationOptions(),
|
||||
);
|
||||
const moveSchedule = useMutation(
|
||||
api.trainScheduling.moveBookingSchedule.mutationOptions(),
|
||||
);
|
||||
@@ -237,6 +244,50 @@ export function ScheduleWorkspacePanel({
|
||||
);
|
||||
};
|
||||
|
||||
const toggleLoaded = (
|
||||
bookingId: string,
|
||||
ref: string,
|
||||
next: "LOADED" | "UNLOADED",
|
||||
) => {
|
||||
setLoading
|
||||
.mutateAsync({ id: schedule.id, bookingIds: [bookingId], loadingStatus: next })
|
||||
.then(() => {
|
||||
toast({
|
||||
title:
|
||||
next === "LOADED"
|
||||
? `${ref} marked loaded`
|
||||
: `${ref} marked unloaded`,
|
||||
});
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not update loading status",
|
||||
description: apiErrorMessage(error, "Please try again."),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const doConfirmLoading = () => {
|
||||
confirmLoading
|
||||
.mutateAsync({ id: schedule.id })
|
||||
.then(() => {
|
||||
toast({ title: "Loading confirmed", description: "The train is cleared to dispatch." });
|
||||
onChanged();
|
||||
})
|
||||
.catch((error) =>
|
||||
toast({
|
||||
title: "Could not confirm loading",
|
||||
description: apiErrorMessage(
|
||||
error,
|
||||
"Grant the Djibouti gatepass first, then confirm loading.",
|
||||
),
|
||||
variant: "destructive",
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const doMove = () => {
|
||||
if (!moveBookingId || !moveTarget) return;
|
||||
moveSchedule
|
||||
@@ -268,7 +319,7 @@ export function ScheduleWorkspacePanel({
|
||||
<div>
|
||||
<Text fw={700}>Allocation workspace</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Manually add ready-to-pay bookings, remove, or reassign them
|
||||
Manually add paid, unassigned bookings, remove, or reassign them
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -347,17 +398,65 @@ export function ScheduleWorkspacePanel({
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
{/* Loading confirmation — required before dispatch for import-Djibouti
|
||||
trains; shown for every direction so staff have one place to confirm. */}
|
||||
{canManage ? (
|
||||
<Group
|
||||
gap={10}
|
||||
p="sm"
|
||||
wrap="nowrap"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
background: schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-0)"
|
||||
: "var(--mantine-color-yellow-0)",
|
||||
border: `1px solid ${
|
||||
schedule.loadingConfirmed
|
||||
? "var(--mantine-color-edr-green-2)"
|
||||
: "var(--mantine-color-yellow-3)"
|
||||
}`,
|
||||
}}
|
||||
>
|
||||
<Group gap={8} wrap="nowrap" align="center" style={{ minWidth: 0 }}>
|
||||
{schedule.loadingConfirmed ? (
|
||||
<CheckCircle2 size={18} color="var(--mantine-color-edr-green-7)" />
|
||||
) : (
|
||||
<PackageCheck size={18} color="#B7791F" />
|
||||
)}
|
||||
<Text size="sm" fw={600}>
|
||||
{schedule.loadingConfirmed
|
||||
? "Loading confirmed — cleared to dispatch"
|
||||
: "Confirm loading before dispatching this train"}
|
||||
</Text>
|
||||
</Group>
|
||||
{!schedule.loadingConfirmed ? (
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<CheckCircle2 size={14} />}
|
||||
loading={confirmLoading.isPending}
|
||||
onClick={doConfirmLoading}
|
||||
>
|
||||
Confirm loading
|
||||
</Button>
|
||||
) : null}
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
{/* Two-panel board */}
|
||||
<Group align="stretch" gap="lg" grow wrap="wrap">
|
||||
{/* Pool */}
|
||||
<PanelColumn
|
||||
title="Ready to pay"
|
||||
hint="Accepted · this route & day"
|
||||
title="Paid · unassigned"
|
||||
hint="Paid · this route & day · not on a train"
|
||||
count={pool.length}
|
||||
accent="#F2A516"
|
||||
loading={poolQuery.isLoading}
|
||||
emptyIcon={Inbox}
|
||||
emptyText="No ready-to-pay bookings waiting for this train."
|
||||
emptyText="No paid, unassigned bookings waiting for this train."
|
||||
>
|
||||
{pool.map((b) => (
|
||||
<BookingCard
|
||||
@@ -402,9 +501,53 @@ export function ScheduleWorkspacePanel({
|
||||
customer={b.customer}
|
||||
weightTons={b.weightTons}
|
||||
status={b.status}
|
||||
loadingStatus={b.wagonAssigned ? b.loadingStatus ?? "UNLOADED" : undefined}
|
||||
right={
|
||||
canManage ? (
|
||||
<Group gap={6} wrap="nowrap" justify="flex-end">
|
||||
{b.wagonAssigned ? (
|
||||
<Tooltip
|
||||
label={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Mark cargo unloaded from wagon"
|
||||
: "Mark cargo loaded onto wagon"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "light"
|
||||
: "filled"
|
||||
}
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED" ? (
|
||||
<PackageX size={13} />
|
||||
) : (
|
||||
<PackageCheck size={13} />
|
||||
)
|
||||
}
|
||||
loading={setLoading.isPending}
|
||||
onClick={() =>
|
||||
toggleLoaded(
|
||||
b.id,
|
||||
b.reference ?? b.id.slice(0, 8),
|
||||
(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "UNLOADED"
|
||||
: "LOADED",
|
||||
)
|
||||
}
|
||||
>
|
||||
{(b.loadingStatus ?? "UNLOADED") === "LOADED"
|
||||
? "Unload"
|
||||
: "Load"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{/* Reassign-to-another-train — hidden for now.
|
||||
<Tooltip label="Reassign to another train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -420,6 +563,7 @@ export function ScheduleWorkspacePanel({
|
||||
Move
|
||||
</Button>
|
||||
</Tooltip>
|
||||
*/}
|
||||
<Tooltip label="Remove from this train" withArrow>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -565,12 +709,14 @@ function BookingCard({
|
||||
customer,
|
||||
weightTons,
|
||||
status,
|
||||
loadingStatus,
|
||||
right,
|
||||
}: {
|
||||
reference: string;
|
||||
customer?: string | null;
|
||||
weightTons?: number | null;
|
||||
status?: string | null;
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
right?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
@@ -596,6 +742,16 @@ function BookingCard({
|
||||
{reference}
|
||||
</Text>
|
||||
{status ? <BookingStatusBadge status={status} /> : null}
|
||||
{loadingStatus ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
radius="sm"
|
||||
variant={loadingStatus === "LOADED" ? "filled" : "light"}
|
||||
color={loadingStatus === "LOADED" ? "edr-green" : "gray"}
|
||||
>
|
||||
{loadingStatus === "LOADED" ? "Loaded" : "Unloaded"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group gap={10} align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
|
||||
@@ -70,7 +70,6 @@ export const QUERY_KEYS = {
|
||||
["contracts", "clearance-queue", region ?? "ET"] as const,
|
||||
clearanceHistory: (region?: string) =>
|
||||
["contracts", "clearance-history", region ?? "ET"] as const,
|
||||
djSchedules: ["contracts", "clearance-dj-schedules"] as const,
|
||||
milestones: (id: string) => ["contracts", "milestones", id] as const,
|
||||
capacity: (id: string) => ["contracts", "capacity", id] as const,
|
||||
bookingMilestones: (bookingId: string) =>
|
||||
|
||||
@@ -232,11 +232,6 @@ export const URL_CONSTANTS = {
|
||||
`/contracts/bookings/${bookingId}/t1-documents`,
|
||||
BOOKING_T1_CLOSE: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/t1-close`,
|
||||
CLEARANCE_DJ_SCHEDULES: "/contracts/clearance/dj-schedules",
|
||||
CLEARANCE_SCHEDULE_GATEPASS: (scheduleId: string) =>
|
||||
`/contracts/clearance/schedules/${scheduleId}/gatepass`,
|
||||
BOOKING_GATEPASS: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/gatepass`,
|
||||
BOOKING_FINAL_INVOICE: (bookingId: string) =>
|
||||
`/contracts/bookings/${bookingId}/final-invoice`,
|
||||
BOOKING_FINAL_INVOICE_CONFIRM: (bookingId: string) =>
|
||||
@@ -286,6 +281,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/assign-unassigned-booking`,
|
||||
BOOKING_WINDOW: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/booking-window`,
|
||||
WINDOW_RULE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/window-rule`,
|
||||
SCHEDULE_DATE: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/schedule-date`,
|
||||
CONTRACT_BOOKING_WINDOWS: (contractId: string) =>
|
||||
`/train-scheduling/contracts/${contractId}/booking-windows`,
|
||||
MARK_BOOKING_PAID: (bookingId: string) =>
|
||||
@@ -329,6 +328,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/import-loading-bookings`,
|
||||
IMPORT_LOADING_STATUS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-loading-status`,
|
||||
LOADING_STATUS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/loading-status`,
|
||||
CONFIRM_LOADING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/confirm-loading`,
|
||||
IMPORT_DJIBOUTI: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti`,
|
||||
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||
import {
|
||||
NotificationBell,
|
||||
NotificationDrawer,
|
||||
NotificationToast,
|
||||
type NotificationItemData,
|
||||
} from "@edr/ui-common";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
resolveNotificationHref,
|
||||
resolveNotificationVisual,
|
||||
} from "./notificationConfig";
|
||||
import {
|
||||
useInfiniteNotifications,
|
||||
useMarkAllRead,
|
||||
useMarkRead,
|
||||
useUnreadCount,
|
||||
} from "./useNotifications";
|
||||
import { useNotificationSocket } from "./useNotificationSocket";
|
||||
|
||||
/** Map a server notification into the shared presentational item shape. */
|
||||
function toItem(n: NotificationDto): NotificationItemData {
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
createdAt: n.createdAt,
|
||||
isRead: n.isRead,
|
||||
priority: n.priority,
|
||||
link: n.link,
|
||||
data: n.data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||
function toItems(
|
||||
data: { pages: NotificationListResult[] } | undefined,
|
||||
): NotificationItemData[] {
|
||||
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires react-query (infinite unread/read lists) + the notification WebSocket
|
||||
* into the shared bell + drawer. Lists are only fetched while the drawer is
|
||||
* open; the badge is driven by the lightweight unread-count query + socket.
|
||||
*/
|
||||
export default function NotificationBellContainer({
|
||||
enabled = true,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const unreadQ = useInfiniteNotifications(false, enabled && opened);
|
||||
const readQ = useInfiniteNotifications(true, enabled && opened);
|
||||
const unread = useUnreadCount(enabled);
|
||||
const markRead = useMarkRead();
|
||||
const markAllRead = useMarkAllRead();
|
||||
|
||||
const unreadItems = toItems(unreadQ.data);
|
||||
const readItems = toItems(readQ.data);
|
||||
const unreadCount = unread.data ?? 0;
|
||||
|
||||
const handleItemClick = (item: NotificationItemData) => {
|
||||
if (!item.isRead) markRead.mutate(item.id);
|
||||
const href = resolveNotificationHref(item);
|
||||
setOpened(false);
|
||||
if (href) navigate(href);
|
||||
};
|
||||
|
||||
// Live push → rich toast that reuses the same registry + click action.
|
||||
useNotificationSocket(enabled, (n) => {
|
||||
const item = toItem(n);
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<NotificationToast
|
||||
item={item}
|
||||
visual={resolveNotificationVisual(item)}
|
||||
visible={t.visible}
|
||||
onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
handleItemClick(item);
|
||||
}}
|
||||
onDismiss={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{ duration: 6000 },
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationBell
|
||||
unreadCount={unreadCount}
|
||||
onClick={() => setOpened(true)}
|
||||
/>
|
||||
<NotificationDrawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
unread={unreadItems}
|
||||
read={readItems}
|
||||
unreadCount={unreadCount}
|
||||
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
|
||||
hasMoreUnread={unreadQ.hasNextPage}
|
||||
hasMoreRead={readQ.hasNextPage}
|
||||
loadingMoreUnread={unreadQ.isFetchingNextPage}
|
||||
loadingMoreRead={readQ.isFetchingNextPage}
|
||||
onLoadMoreUnread={() => {
|
||||
if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) {
|
||||
void unreadQ.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onLoadMoreRead={() => {
|
||||
if (readQ.hasNextPage && !readQ.isFetchingNextPage) {
|
||||
void readQ.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onItemClick={handleItemClick}
|
||||
onMarkAllRead={() => markAllRead.mutate()}
|
||||
resolveVisual={resolveNotificationVisual}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NotificationType } from "@edr/types";
|
||||
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
||||
import { Bell, ClipboardCheck, Inbox, Wallet } from "lucide-react";
|
||||
|
||||
const ICON_SIZE = 17;
|
||||
|
||||
/**
|
||||
* Backoffice notification registry. Maps a notification `type` → icon + Mantine
|
||||
* color, and `type`/`data` → an in-app deep link. This is the single place to
|
||||
* customize how each staff-facing notification looks and where it goes.
|
||||
*/
|
||||
export function resolveNotificationVisual(
|
||||
item: NotificationItemData,
|
||||
): NotificationVisual {
|
||||
switch (item.type) {
|
||||
case NotificationType.REQUEST_SUBMITTED:
|
||||
return { icon: <Inbox size={ICON_SIZE} />, color: "blue" };
|
||||
case NotificationType.PAYMENT_RECEIVED:
|
||||
return { icon: <Wallet size={ICON_SIZE} />, color: "teal" };
|
||||
case NotificationType.CLEARANCE_REVIEW:
|
||||
return { icon: <ClipboardCheck size={ICON_SIZE} />, color: "orange" };
|
||||
default:
|
||||
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
||||
}
|
||||
}
|
||||
|
||||
function asId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where clicking a notification navigates. Prefers an explicit
|
||||
* server-provided `link`, else derives a `/dashboard/*` route from `type` +
|
||||
* `data`. Returns `null` when there's nowhere sensible to go.
|
||||
*/
|
||||
export function resolveNotificationHref(
|
||||
item: NotificationItemData,
|
||||
): string | null {
|
||||
if (item.link) return item.link;
|
||||
const data = item.data ?? {};
|
||||
switch (item.type) {
|
||||
case NotificationType.REQUEST_SUBMITTED:
|
||||
return "/dashboard/booking-requests";
|
||||
case NotificationType.PAYMENT_RECEIVED: {
|
||||
const id = asId(data.customerId);
|
||||
return id ? `/dashboard/customers/${id}` : "/dashboard/customers";
|
||||
}
|
||||
case NotificationType.CLEARANCE_REVIEW:
|
||||
return "/dashboard/arrival-queue";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { NotificationListResult } from "@edr/types";
|
||||
|
||||
import { api } from "@/auth/http";
|
||||
|
||||
export interface ListNotificationsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backoffice notification REST calls. The backoffice axios `api` response
|
||||
* interceptor already unwraps the `{ success, data }` envelope, so `.data` here
|
||||
* is the payload itself.
|
||||
*/
|
||||
export const notificationsApi = {
|
||||
list: async (
|
||||
params: ListNotificationsParams = {},
|
||||
): Promise<NotificationListResult> => {
|
||||
const { data } = await api.get<NotificationListResult>("/notifications", {
|
||||
params,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
unreadCount: async (): Promise<number> => {
|
||||
const { data } = await api.get<{ unreadCount: number }>(
|
||||
"/notifications/unread-count",
|
||||
);
|
||||
return data.unreadCount;
|
||||
},
|
||||
markRead: async (id: string): Promise<void> => {
|
||||
await api.patch(`/notifications/${id}/read`);
|
||||
},
|
||||
markAllRead: async (): Promise<void> => {
|
||||
await api.post("/notifications/read-all");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
NOTIFICATION_WS_EVENTS,
|
||||
NOTIFICATION_WS_NAMESPACE,
|
||||
type NotificationDto,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
|
||||
|
||||
import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications";
|
||||
|
||||
// The socket namespace lives at the server root, not under the `/api` REST
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
/**
|
||||
* Subscribes to live notification pushes for the signed-in staff user. New
|
||||
* items invalidate the cached lists + fire `onNew` (the host shows a rich
|
||||
* toast); unread-count pushes update the badge.
|
||||
*/
|
||||
export function useNotificationSocket(
|
||||
enabled: boolean,
|
||||
onNew?: (notification: NotificationDto) => void,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const onNewRef = useRef(onNew);
|
||||
onNewRef.current = onNew;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const token = getCookie(AUTH_TOKEN_COOKIE);
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
onNewRef.current?.(n);
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||
if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [enabled, qc]);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { notificationsApi } from "./notificationsApi";
|
||||
|
||||
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Paginated (infinite) notifications for one read-state. Drives a drawer
|
||||
* section; call `fetchNextPage` as the user scrolls. Each page carries the
|
||||
* server `count` so we know when to stop.
|
||||
*/
|
||||
export function useInfiniteNotifications(isRead: boolean, enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0);
|
||||
return loaded < lastPage.count ? allPages.length + 1 : undefined;
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: UNREAD_KEY,
|
||||
queryFn: () => notificationsApi.unreadCount(),
|
||||
enabled,
|
||||
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => notificationsApi.markAllRead(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -68,15 +68,6 @@ export function useDjClearanceQueue(enabled = true) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||
export function useDjClearanceSchedules(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: QUERY_KEYS.CONTRACTS.djSchedules,
|
||||
queryFn: () => contractsService.getDjClearanceSchedules(),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Path A self-clearance queue (Operations reviews non-customs contracts). */
|
||||
export function useOpsClearanceQueue(enabled = true) {
|
||||
return useQuery({
|
||||
|
||||
@@ -174,6 +174,23 @@ export const useContainerTypeOptions = (
|
||||
buildContainerTypeSelectOptions(result.data ?? [], includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Active wagon-type options for the cargo-type / container-type "Wagon type"
|
||||
* picker. The FK the selection sets drives train-scheduling wagon resolution.
|
||||
*/
|
||||
export const useWagonTypeOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
...api.wagonTypes.list.queryOptions(),
|
||||
enabled,
|
||||
select: (rows: { id: string; code: string; name: string; isActive?: boolean }[]) =>
|
||||
rows
|
||||
.filter((wt) => wt.isActive !== false)
|
||||
.map((wt) => ({
|
||||
label: wt.name ? `${wt.name} (${wt.code})` : wt.code,
|
||||
value: wt.id,
|
||||
})),
|
||||
});
|
||||
|
||||
const LIVE_RATE_PAGE_SIZE = 500;
|
||||
|
||||
export const useLiveRateOptions = (enabled = true) =>
|
||||
|
||||
@@ -73,15 +73,23 @@ export function getPermissionKeys(user: AuthUser | null | undefined): string[] {
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
/** Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl"). */
|
||||
/**
|
||||
* Position keys held by the user (e.g. "ethiopian_gl", "djibouti_gl").
|
||||
* Tolerates IAM payload shape variants: the key flat on the employee position,
|
||||
* nested under `position.key`, or the GL modeled as a role instead.
|
||||
*/
|
||||
export function getPositionKeys(user: AuthUser | null | undefined): string[] {
|
||||
if (!user) return [];
|
||||
const keys = new Set<string>();
|
||||
for (const emp of user.employee ?? []) {
|
||||
for (const pos of emp.positions ?? []) {
|
||||
if (pos.key) keys.add(pos.key);
|
||||
if (pos.position?.key) keys.add(pos.position.key);
|
||||
}
|
||||
}
|
||||
for (const role of user.roles ?? []) {
|
||||
if (role.key) keys.add(role.key);
|
||||
}
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,162 +1,40 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowUpRight,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
PasswordInput,
|
||||
PinInput,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, ArrowLeft } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
/** Normalise Ethiopian local phone (09…/07…) to E.164; pass email through unchanged. */
|
||||
const normaliseIdentifier = (raw: string): string => {
|
||||
const v = raw.trim();
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length >= 9 && (v.startsWith("0") || v.startsWith("+251"))) {
|
||||
const local = digits.startsWith("251") ? digits.slice(3) : digits.replace(/^0/, "");
|
||||
const local = digits.startsWith("251")
|
||||
? digits.slice(3)
|
||||
: digits.replace(/^0/, "");
|
||||
return `+251${local}`;
|
||||
}
|
||||
return v.toLowerCase();
|
||||
};
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
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 hidden shrink-0 flex-col overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex 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();
|
||||
@@ -165,7 +43,6 @@ const LoginPage = () => {
|
||||
const [otp, setOtp] = useState("");
|
||||
const [needsMfa, setNeedsMfa] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [normalizedIdentifier, setNormalizedIdentifier] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -179,15 +56,14 @@ const LoginPage = () => {
|
||||
setNormalizedIdentifier(normalized);
|
||||
|
||||
const result = await login({ email: normalized, password });
|
||||
console.log(result);
|
||||
if (result.mfaRequired) {
|
||||
setNeedsMfa(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to sign in with those credentials.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -201,194 +77,132 @@ const LoginPage = () => {
|
||||
try {
|
||||
await verifyMfa({ email: normalizedIdentifier, otp: otp.trim() });
|
||||
navigate("/dashboard/overview", { replace: true });
|
||||
} catch {
|
||||
setError("Unable to verify the one-time code.");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
<Box component="form" onSubmit={handleSubmit}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<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">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Welcome back!
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Log in to access the freight backoffice & explore all logistics
|
||||
resources.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
/>
|
||||
</div>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<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>
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={submitting}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : 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>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={submitting}>
|
||||
Sign In
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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>
|
||||
<Box component="form" onSubmit={handleVerifyMfa}>
|
||||
<Center mb={{ base: "md", sm: "lg" }}>
|
||||
<Image src={EDR_LOGO} alt="EDR Freight" h={{ base: 36, sm: 44 }} w="auto" fit="contain" />
|
||||
</Center>
|
||||
|
||||
<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">
|
||||
<Stack gap={6} mb={{ base: "md", sm: "lg" }} ta="center">
|
||||
<Title order={1} fz={{ base: "xl", sm: "26px" }} fw={700} lh={1.2}>
|
||||
Multi-factor verification
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
We sent a verification code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
<Text component="span" fw={500} c="var(--mantine-color-text)">
|
||||
{normalizedIdentifier}
|
||||
</span>
|
||||
</Text>
|
||||
. Enter it below to complete sign in.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
<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
|
||||
<Stack gap="md">
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otp}
|
||||
onChange={(event) => setOtp(event.target.value)}
|
||||
placeholder="Enter the code"
|
||||
className={fieldClass}
|
||||
placeholder="0"
|
||||
disabled={submitting}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtp}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className="flex w-full gap-3">
|
||||
<button
|
||||
<Group grow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={submitting}
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className={`${primaryButtonClass} min-w-0 flex-1`}
|
||||
color="edr-green"
|
||||
loading={submitting}
|
||||
disabled={otp.trim().length !== 6}
|
||||
>
|
||||
{submitting ? "Verifying..." : "Verify"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
Verify
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
|
||||
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 max-w-xl rounded-3xl 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>
|
||||
</>
|
||||
);
|
||||
return <AuthShell>{!needsMfa ? loginForm : mfaForm}</AuthShell>;
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function ContractClearanceDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { view, viewer } = useFileViewer();
|
||||
|
||||
const { data: contract } = useContractDetail(id);
|
||||
const { data: contract, refetch: refetchContract } = useContractDetail(id);
|
||||
const {
|
||||
data: clearance,
|
||||
isLoading,
|
||||
@@ -250,6 +250,7 @@ export default function ContractClearanceDetailPage() {
|
||||
phasedCustoms={phasedCustoms}
|
||||
onChanged={() => {
|
||||
void refetch();
|
||||
void refetchContract();
|
||||
void refetchBookingMilestones();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -596,21 +596,58 @@ export default function ContractRequestDetailPage() {
|
||||
No cargo scope lines.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="xs">
|
||||
{(contract.cargoScope ?? []).map((s) => (
|
||||
<Group key={s.id} gap={8} wrap="nowrap">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
/>
|
||||
<Text size="sm">
|
||||
{s.containerSize ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoTypeId ??
|
||||
"Cargo"}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
<Stack gap="sm">
|
||||
{(contract.cargoScope ?? []).map((s) => {
|
||||
const isContainer = Boolean(s.containerSize);
|
||||
// Bulk lines carry their commodity detail (name + unit);
|
||||
// container lines carry the size (20ft / 40ft).
|
||||
const title = isContainer
|
||||
? `${s.containerSize} container`
|
||||
: (s.cargoType?.cargoTypeName ??
|
||||
s.cargoFreeText ??
|
||||
s.cargoType?.code ??
|
||||
"Bulk cargo");
|
||||
// quantityCap unit: containers for a size line, else the
|
||||
// cargo type's unit of measure (tons / items / …), default tons.
|
||||
const capUnit = isContainer
|
||||
? "containers"
|
||||
: (s.cargoType?.unitOfMeasure?.toLowerCase() ?? "tons");
|
||||
return (
|
||||
<Group key={s.id} gap={8} wrap="nowrap" align="flex-start">
|
||||
<BoxIcon
|
||||
size={15}
|
||||
color="var(--mantine-color-edr-green-6)"
|
||||
style={{ marginTop: 2, flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{title}
|
||||
</Text>
|
||||
<Group gap={6} mt={2}>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={isContainer ? "blue" : "grape"}
|
||||
radius="sm"
|
||||
size="xs"
|
||||
tt="uppercase"
|
||||
>
|
||||
{isContainer ? "Container" : "Bulk"}
|
||||
</Badge>
|
||||
{s.cargoType?.code ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Code: {s.cargoType.code}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
{s.quantityCap != null
|
||||
? `Cap: ${s.quantityCap} ${capUnit}`
|
||||
: "Cap: uncapped"}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
@@ -1,326 +1,65 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import { ChevronRight, Ship, Train, Truck } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
import type { Freight } from "@edr/types";
|
||||
import toast from "react-hot-toast";
|
||||
import { Badge, Card, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { ChevronRight, Ship } from "lucide-react";
|
||||
|
||||
import { PageContainer } from "@/components/page/PageContainer";
|
||||
import { PageHeader } from "@/components/page/PageHeader";
|
||||
import {
|
||||
useDjClearanceQueue,
|
||||
useDjClearanceSchedules,
|
||||
} from "@/hooks/contracts/useContracts";
|
||||
import { contractsService } from "@/services/contracts.service";
|
||||
import { useDjClearanceQueue } from "@/hooks/contracts/useContracts";
|
||||
|
||||
export default function GlDjiboutiClearanceListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: contractQueue, isLoading: contractsLoading } = useDjClearanceQueue();
|
||||
const schedulesQuery = useDjClearanceSchedules();
|
||||
|
||||
const contractItems = contractQueue?.items ?? [];
|
||||
const scheduleItems = schedulesQuery.data ?? [];
|
||||
|
||||
const [gatepassTarget, setGatepassTarget] =
|
||||
useState<Freight.DjClearanceSchedule | null>(null);
|
||||
const [gatepassAt, setGatepassAt] = useState<Date | null>(new Date());
|
||||
const [granting, setGranting] = useState(false);
|
||||
|
||||
const columns = useMemo<ColumnDef<Freight.DjClearanceSchedule>[]>(
|
||||
() => [
|
||||
{
|
||||
header: "Train",
|
||||
accessorKey: "trainNumber",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={700}>
|
||||
{row.original.trainNumber ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Route",
|
||||
id: "route",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.origin ?? "—"} → {row.original.destination ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Scheduled departure",
|
||||
id: "scheduled",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.scheduledDepartureDate
|
||||
? new Date(row.original.scheduledDepartureDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Departed",
|
||||
id: "departed",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.actualDepartureAt
|
||||
? new Date(row.original.actualDepartureAt).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Arrived",
|
||||
id: "arrived",
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.actualArrivalAt
|
||||
? new Date(row.original.actualArrivalAt).toLocaleString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Status",
|
||||
accessorKey: "status",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={statusColor(row.original.status)} radius="sm">
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: "Customs bookings",
|
||||
id: "customs",
|
||||
cell: ({ row }) => {
|
||||
const bookings = row.original.customsBookings;
|
||||
const directions = [...new Set(bookings.map((b) => b.tradeDirection))];
|
||||
return (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
{bookings.length}
|
||||
</Badge>
|
||||
{directions.map((d) => (
|
||||
<Badge key={d} variant="outline" color={d === "IMPORT" ? "edr-green" : "blue"} radius="sm">
|
||||
{d}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "Gate pass",
|
||||
id: "gatepass",
|
||||
cell: ({ row }) => {
|
||||
const bookings = row.original.customsBookings;
|
||||
const allGranted =
|
||||
bookings.length > 0 && bookings.every((b) => b.gatepassGranted);
|
||||
const grantedAt = bookings.find((b) => b.gatepassAt)?.gatepassAt ?? null;
|
||||
if (allGranted) {
|
||||
return (
|
||||
<Badge variant="light" color="edr-green" radius="sm">
|
||||
Granted{grantedAt ? ` · ${new Date(grantedAt).toLocaleString()}` : ""}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
color="edr-green"
|
||||
leftSection={<Truck size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setGatepassAt(new Date());
|
||||
setGatepassTarget(row.original);
|
||||
}}
|
||||
>
|
||||
Gate pass
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="GL Djibouti — Clearance"
|
||||
subtitle="Customs contracts handed off to Djibouti GL, plus train schedules for gate-pass control."
|
||||
subtitle="Customs contracts handed off to Djibouti GL."
|
||||
/>
|
||||
<Tabs defaultValue="contracts" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="contracts">Contracts ({contractItems.length})</Tabs.Tab>
|
||||
<Tabs.Tab value="schedules" leftSection={<Train size={14} />}>
|
||||
Schedules ({scheduleItems.length})
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="contracts">
|
||||
{contractsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{contractItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs contracts yet.
|
||||
</Text>
|
||||
) : (
|
||||
contractItems.map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
Contract
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="schedules">
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={scheduleItems}
|
||||
status={
|
||||
schedulesQuery.isLoading
|
||||
? "loading"
|
||||
: schedulesQuery.isError
|
||||
? "error"
|
||||
: "success"
|
||||
}
|
||||
error={
|
||||
schedulesQuery.isError
|
||||
? {
|
||||
message: "Failed to load train schedules.",
|
||||
onRetry: () => void schedulesQuery.refetch(),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
emptyMessage="No train schedules carry customs bookings yet."
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Modal
|
||||
opened={gatepassTarget != null}
|
||||
onClose={() => setGatepassTarget(null)}
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Truck size={18} />
|
||||
<Text fw={700}>
|
||||
Gate pass — train {gatepassTarget?.trainNumber ?? ""}
|
||||
{contractsLoading ? (
|
||||
<Group justify="center" py={60}>
|
||||
<Loader color="edr-green" />
|
||||
</Group>
|
||||
) : (
|
||||
<Stack gap="sm">
|
||||
{contractItems.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No Djibouti customs contracts yet.
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
radius="md"
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Grants the gate pass for all{" "}
|
||||
{gatepassTarget?.customsBookings.length ?? 0} customs booking
|
||||
{(gatepassTarget?.customsBookings.length ?? 0) === 1 ? "" : "s"} on this
|
||||
train.
|
||||
</Text>
|
||||
<DateTimePicker
|
||||
label="Gate pass time"
|
||||
value={gatepassAt}
|
||||
onChange={(v) => setGatepassAt(v ? new Date(v) : null)}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setGatepassTarget(null)}
|
||||
disabled={granting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
loading={granting}
|
||||
leftSection={<Truck size={16} />}
|
||||
onClick={async () => {
|
||||
if (!gatepassTarget) return;
|
||||
setGranting(true);
|
||||
try {
|
||||
const result = await contractsService.grantScheduleGatepass(
|
||||
gatepassTarget.id,
|
||||
(gatepassAt ?? new Date()).toISOString(),
|
||||
);
|
||||
if (result.skipped.length > 0) {
|
||||
toast.error(
|
||||
`${result.granted} granted, ${result.skipped.length} skipped: ${result.skipped[0]?.error ?? ""}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
`Gate pass granted for ${result.granted} booking${result.granted === 1 ? "" : "s"}`,
|
||||
);
|
||||
}
|
||||
setGatepassTarget(null);
|
||||
void schedulesQuery.refetch();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "Failed");
|
||||
} finally {
|
||||
setGranting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Grant gate pass
|
||||
</Button>
|
||||
</Group>
|
||||
) : (
|
||||
contractItems.map((c) => (
|
||||
<Card
|
||||
key={c.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/dashboard/gl-djibouti/clearance/${c.id}`)}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm">
|
||||
<Ship size={18} className="text-[color:var(--freight-brand)]" />
|
||||
<div>
|
||||
<Text fw={700}>{c.reference}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{c.tradeDirection} · {c.status}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<Badge variant="light" color="edr-green">
|
||||
Contract
|
||||
</Badge>
|
||||
<ChevronRight size={18} className="text-muted-foreground" />
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
)}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function statusColor(status: string): string {
|
||||
switch (status) {
|
||||
case "SCHEDULED":
|
||||
return "blue";
|
||||
case "DISPATCHED":
|
||||
return "yellow";
|
||||
case "ARRIVED":
|
||||
return "edr-green";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
import {
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
useWagonTypeOptions,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
@@ -53,6 +54,8 @@ interface CargoNode extends RuleEngineRecord {
|
||||
requiresDirectorApproval?: boolean;
|
||||
/** How this cargo is measured (PER_TON / PER_ITEM); null for groups/unset. */
|
||||
unitOfMeasure?: string | null;
|
||||
/** Wagon type FK used to carry this bulk cargo during scheduling; null if unset. */
|
||||
wagonTypeId?: string | null;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
@@ -78,6 +81,18 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Wagon type that carries this (bulk) commodity — drives train-scheduling
|
||||
// wagon resolution. Optional: leave "None" for grouping categories and
|
||||
// container/legacy cargo; set it on scheduled bulk commodities.
|
||||
// Options injected at render from useWagonTypeOptions.
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
optional: true,
|
||||
placeholder: "Select wagon type (bulk cargo)",
|
||||
options: [{ label: "None", value: RULE_ENGINE_SELECT_NONE }],
|
||||
},
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
@@ -104,6 +119,24 @@ const CargoTypesPage = () => {
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
// Wagon-type options for the "Wagon type" picker (bulk cargo → wagon FK).
|
||||
const { data: wagonTypeOptions } = useWagonTypeOptions(canManage);
|
||||
const formFields = useMemo<FormFieldDef[]>(
|
||||
() =>
|
||||
FORM_FIELDS.map((field) =>
|
||||
field.name === "wagonTypeId"
|
||||
? {
|
||||
...field,
|
||||
options: [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
...(wagonTypeOptions ?? []),
|
||||
],
|
||||
}
|
||||
: field,
|
||||
),
|
||||
[wagonTypeOptions],
|
||||
);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||
@@ -349,7 +382,7 @@ const CargoTypesPage = () => {
|
||||
? "Create a top-level cargo category."
|
||||
: "Create a cargo type inside this category. It's attached here automatically."
|
||||
}
|
||||
fields={FORM_FIELDS}
|
||||
fields={formFields}
|
||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useWagonTypeOptions,
|
||||
useRateWorkflow,
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
@@ -151,6 +152,9 @@ const RuleEngineResourcePage = () => {
|
||||
const usesLiveRateField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "rateId"),
|
||||
);
|
||||
const usesWagonTypeField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "wagonTypeId"),
|
||||
);
|
||||
|
||||
const { data: cargoParentOptions, isLoading: cargoParentOptionsLoading } =
|
||||
useCargoTypeParentOptions(editingId, config?.slug === "cargo-types");
|
||||
@@ -160,6 +164,8 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(config?.slug === "rates", usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
|
||||
const formFields = useMemo(() => {
|
||||
if (!config) return [];
|
||||
@@ -193,9 +199,16 @@ const RuleEngineResourcePage = () => {
|
||||
options: liveRateOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "wagonTypeId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: wagonTypeOptions ?? [],
|
||||
};
|
||||
}
|
||||
return field;
|
||||
});
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions]);
|
||||
}, [config, cargoParentOptions, cargoLeafOptions, containerTypeOptions, liveRateOptions, wagonTypeOptions]);
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const meta = data?.meta;
|
||||
@@ -502,7 +515,8 @@ const RuleEngineResourcePage = () => {
|
||||
(config.slug === "cargo-types" && cargoParentOptionsLoading) ||
|
||||
(usesContainerTypeField && containerTypeOptionsLoading) ||
|
||||
(usesCargoTypeField && cargoLeafOptionsLoading) ||
|
||||
(usesLiveRateField && liveRateOptionsLoading)
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
positionLoading={createPositionLoading}
|
||||
|
||||
@@ -248,6 +248,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||
{
|
||||
name: "wagonTypeId",
|
||||
label: "Wagon type",
|
||||
type: "select",
|
||||
required: true,
|
||||
description: "Wagon type used to carry this container during train scheduling.",
|
||||
},
|
||||
{ name: "isOpenTop", label: "Open top", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
],
|
||||
|
||||
@@ -1,25 +1,28 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
RingProgress,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
CalendarClock,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
@@ -45,9 +48,10 @@ import {
|
||||
} from "@/components/trainScheduling/containerPlacement.util";
|
||||
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
|
||||
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
|
||||
import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
// import { ScheduleBatchPanel } from "@/components/trainScheduling/ScheduleBatchPanel";
|
||||
import { ScheduleBookingsStep } from "@/components/trainScheduling/ScheduleBookingsStep";
|
||||
import { ScheduleWorkspacePanel } from "@/components/trainScheduling/ScheduleWorkspacePanel";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
@@ -95,10 +99,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
const [maintenanceOpen, setMaintenanceOpen] = useState(false);
|
||||
const [windowSettingsOpen, setWindowSettingsOpen] = useState(false);
|
||||
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
|
||||
const [gatepassReference, setGatepassReference] = useState("");
|
||||
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
|
||||
const [gatepassNotes, setGatepassNotes] = useState("");
|
||||
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
|
||||
const autoPreviewedRef = useRef(false);
|
||||
|
||||
const detailQuery = useQuery(
|
||||
@@ -125,6 +131,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
|
||||
enabled: Boolean(scheduleId && gatepassApplies),
|
||||
});
|
||||
const gatepassSecured = gatepassQuery.data?.gatepassStatus === "SECURED";
|
||||
const secureGatepass = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
|
||||
@@ -146,12 +153,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const importLoadingQuery = useQuery(
|
||||
api.trainScheduling.importLoadingBookings.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
|
||||
}),
|
||||
);
|
||||
// Superseded by the Workspace tab Load/Unload toggle — see commented
|
||||
// "Import loading confirmation" card below.
|
||||
// const importLoadingQuery = useQuery(
|
||||
// api.trainScheduling.importLoadingBookings.queryOptions({
|
||||
// input: { id: scheduleId ?? "" },
|
||||
// enabled: Boolean(scheduleId && schedule?.direction === "IMPORT"),
|
||||
// }),
|
||||
// );
|
||||
|
||||
const eligibleFilters = useMemo(
|
||||
() =>
|
||||
@@ -358,6 +367,22 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canEditBookings = ["DRAFT", "SCHEDULED"].includes(schedule.status);
|
||||
const canFinalize = schedule.status === "DRAFT" && (schedule.bookings?.length ?? 0) > 0;
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
|
||||
// Dispatch readiness: bookings with no wagon, and wagon-loaded bookings whose
|
||||
// cargo staff never marked loaded. Both are warnings, not blockers — staff can
|
||||
// still dispatch after confirming.
|
||||
const dispatchBookings = schedule.bookings ?? [];
|
||||
const unassignedCount = dispatchBookings.filter((b) => !b.wagonAssigned).length;
|
||||
const unloadedCount = dispatchBookings.filter(
|
||||
(b) => b.wagonAssigned && (b.loadingStatus ?? "UNLOADED") !== "LOADED",
|
||||
).length;
|
||||
// Import-Djibouti trains are HARD-blocked from dispatch until loading is
|
||||
// confirmed in the workspace — surface it as a blocker, not just a warning.
|
||||
const loadingBlocksDispatch =
|
||||
schedule.requiresLoadingConfirmation === true &&
|
||||
schedule.loadingConfirmed !== true;
|
||||
const hasDispatchWarnings = unassignedCount > 0 || unloadedCount > 0;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling =
|
||||
@@ -396,6 +421,24 @@ export default function TrainScheduleV2DetailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const runDispatch = async () => {
|
||||
setDispatchConfirmOpen(false);
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!allSelectedIds.length) return;
|
||||
|
||||
@@ -777,22 +820,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
radius="md"
|
||||
leftSection={<Send size={18} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await dispatch.mutateAsync(scheduleId);
|
||||
await openMarshallingDocument({
|
||||
title: "Train dispatched",
|
||||
successDescription: "Marshalling document generated for the dispatched train.",
|
||||
errorTitle: "Train dispatched, but document could not open",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({
|
||||
title: "Dispatch failed",
|
||||
description: parseError(err, "Could not dispatch"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onClick={() => setDispatchConfirmOpen(true)}
|
||||
>
|
||||
Dispatch train
|
||||
</Button>
|
||||
@@ -886,6 +914,18 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Track train
|
||||
</Button>
|
||||
) : null}
|
||||
{schedule.windowPhase === "PRE_WINDOW" ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<Clock size={16} />}
|
||||
onClick={() => setWindowSettingsOpen(true)}
|
||||
>
|
||||
Window settings
|
||||
</Button>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -896,6 +936,31 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Reschedule train
|
||||
</Button>
|
||||
) : null}
|
||||
{gatepassApplies ? (
|
||||
gatepassSecured ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
disabled
|
||||
>
|
||||
Gate pass secured
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Secure gate pass
|
||||
</Button>
|
||||
)
|
||||
) : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
@@ -963,6 +1028,9 @@ export default function TrainScheduleV2DetailPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Import loading confirmation — superseded by the per-booking Load/Unload
|
||||
toggle in the Workspace tab (works for all directions). Kept commented
|
||||
in case the import-only confirmation flow is needed again.
|
||||
{schedule?.direction === "IMPORT" ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
@@ -979,83 +1047,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
|
||||
{gatepassApplies ? (
|
||||
<Paper radius="xl" p="lg" withBorder>
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||
<Group gap="md" align="flex-start" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
size={44}
|
||||
radius="md"
|
||||
variant="light"
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
|
||||
>
|
||||
<FileText size={22} />
|
||||
</ThemeIcon>
|
||||
<Stack gap={4}>
|
||||
<Group gap="sm">
|
||||
<Title order={4} fw={700}>
|
||||
Djibouti Port gate pass
|
||||
</Title>
|
||||
<Badge
|
||||
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
|
||||
variant="light"
|
||||
>
|
||||
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{schedule.direction === "IMPORT"
|
||||
? "Secure before dispatch from Djibouti."
|
||||
: "Secure after dispatch before Djibouti Port entry / unloading."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
|
||||
</Group>
|
||||
|
||||
<Group align="flex-end" grow>
|
||||
<TextInput
|
||||
label="Secured date"
|
||||
type="datetime-local"
|
||||
value={gatepassSecuredAt}
|
||||
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document reference"
|
||||
placeholder="Optional"
|
||||
value={gatepassReference}
|
||||
onChange={(event) => setGatepassReference(event.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Document URL"
|
||||
placeholder="Optional upload/link"
|
||||
value={gatepassFileUrl}
|
||||
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
label="Notes"
|
||||
placeholder="Optional"
|
||||
autosize
|
||||
minRows={2}
|
||||
value={gatepassNotes}
|
||||
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={secureGatepass.isPending}
|
||||
onClick={() => secureGatepass.mutate()}
|
||||
>
|
||||
Save as Secured
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
*/}
|
||||
|
||||
<Tabs defaultValue="workflow" radius="md" color="edr-green" keepMounted={false}>
|
||||
<Tabs.List mb="md">
|
||||
@@ -1126,7 +1118,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<ScheduleBatchPanel schedule={schedule} />
|
||||
{/* <ScheduleBatchPanel schedule={schedule} /> */}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -1150,6 +1142,109 @@ export default function TrainScheduleV2DetailPage() {
|
||||
onComplete={() => void detailQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<BookingWindowSettingsModal
|
||||
scheduleId={scheduleId ?? null}
|
||||
opened={windowSettingsOpen}
|
||||
onClose={() => setWindowSettingsOpen(false)}
|
||||
onSaved={() => void detailQuery.refetch()}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={dispatchConfirmOpen}
|
||||
onClose={() => setDispatchConfirmOpen(false)}
|
||||
centered
|
||||
radius="lg"
|
||||
title={
|
||||
<Group gap={8}>
|
||||
<Send size={18} />
|
||||
<Text fw={700}>Dispatch this train?</Text>
|
||||
</Group>
|
||||
}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Dispatch locks the composition and begins rail movement. This cannot be
|
||||
undone.
|
||||
</Text>
|
||||
|
||||
{loadingBlocksDispatch ? (
|
||||
<Alert
|
||||
color="red"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Loading not confirmed"
|
||||
>
|
||||
This import train cannot depart until loading is confirmed. Use{" "}
|
||||
<Text span fw={700}>
|
||||
Confirm loading
|
||||
</Text>{" "}
|
||||
in the Workspace tab first.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{hasDispatchWarnings ? (
|
||||
<Alert
|
||||
color="orange"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<AlertTriangle size={18} />}
|
||||
title="Some bookings are not fully ready"
|
||||
>
|
||||
<List size="sm" spacing={4}>
|
||||
{unassignedCount > 0 ? (
|
||||
<List.Item>
|
||||
<Text span fw={700}>
|
||||
{unassignedCount}
|
||||
</Text>{" "}
|
||||
booking{unassignedCount === 1 ? "" : "s"} not assigned to a wagon
|
||||
</List.Item>
|
||||
) : null}
|
||||
{unloadedCount > 0 ? (
|
||||
<List.Item>
|
||||
<Text span fw={700}>
|
||||
{unloadedCount}
|
||||
</Text>{" "}
|
||||
wagon-assigned booking{unloadedCount === 1 ? "" : "s"} still marked
|
||||
unloaded
|
||||
</List.Item>
|
||||
) : null}
|
||||
</List>
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
You can still dispatch — confirm to proceed.
|
||||
</Text>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
>
|
||||
All bookings are assigned to a wagon and marked loaded.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setDispatchConfirmOpen(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
disabled={loadingBlocksDispatch}
|
||||
onClick={() => void runDispatch()}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,9 +20,11 @@ import {
|
||||
ArrowRight,
|
||||
Ban,
|
||||
CalendarClock,
|
||||
Clock,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
Navigation,
|
||||
Pencil,
|
||||
Send,
|
||||
Train,
|
||||
Weight,
|
||||
@@ -36,6 +38,8 @@ import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
|
||||
import { FreightTypeBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
import EditScheduleDateModal from "@/components/trainScheduling/EditScheduleDateModal";
|
||||
import {
|
||||
locomotiveOption,
|
||||
showScheduleWarnings,
|
||||
@@ -86,6 +90,9 @@ export default function TrainScheduleV2ListPage() {
|
||||
const [statusFilter, setStatusFilter] = useState("ALL");
|
||||
const [freightFilter, setFreightFilter] = useState("ALL");
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [windowSettingsId, setWindowSettingsId] = useState<string | null>(null);
|
||||
const [editDateSchedule, setEditDateSchedule] =
|
||||
useState<TrainScheduleListItem | null>(null);
|
||||
const [routeId, setRouteId] = useState("");
|
||||
const [scheduleDate, setScheduleDate] = useState("");
|
||||
const [locomotiveIds, setLocomotiveIds] = useState<string[]>([]);
|
||||
@@ -315,6 +322,22 @@ export default function TrainScheduleV2ListPage() {
|
||||
Track
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
leftSection={<Pencil size={15} />}
|
||||
onClick={() => setEditDateSchedule(schedule)}
|
||||
>
|
||||
Edit departure date
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
leftSection={<Clock size={15} />}
|
||||
onClick={() => setWindowSettingsId(schedule.id)}
|
||||
>
|
||||
Booking window settings
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DRAFT", "SCHEDULED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
color="red"
|
||||
@@ -583,6 +606,22 @@ export default function TrainScheduleV2ListPage() {
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<BookingWindowSettingsModal
|
||||
scheduleId={windowSettingsId}
|
||||
opened={windowSettingsId != null}
|
||||
onClose={() => setWindowSettingsId(null)}
|
||||
onSaved={() => void schedulesQuery.refetch()}
|
||||
/>
|
||||
|
||||
<EditScheduleDateModal
|
||||
scheduleId={editDateSchedule?.id ?? null}
|
||||
currentDate={editDateSchedule?.scheduleDate ?? null}
|
||||
routeName={editDateSchedule?.routeName ?? null}
|
||||
opened={editDateSchedule != null}
|
||||
onClose={() => setEditDateSchedule(null)}
|
||||
onSaved={() => void schedulesQuery.refetch()}
|
||||
/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
"importWindowLeadDays",
|
||||
"exportBookingLeadHours",
|
||||
"windowOpenHour",
|
||||
"windowCloseHour",
|
||||
"windowDurationHours",
|
||||
"docReviewMinutes",
|
||||
"paymentWindowMinutes",
|
||||
@@ -196,7 +197,7 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window open hour (EAT)"
|
||||
description="Local hour the import window opens on its booking day (e.g. 8 = 08:00)"
|
||||
description="Local hour the booking desk opens each day (e.g. 8 = 08:00)"
|
||||
value={form.windowOpenHour ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowOpenHour: value }))
|
||||
@@ -207,6 +208,19 @@ export default function TrainSchedulingGlobalRulesPage() {
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Window close hour (EAT)"
|
||||
description="Local hour the booking desk shuts each day; a not-yet-full window resumes next morning at the open hour. Set equal to the open hour for a 24-hour desk."
|
||||
value={form.windowCloseHour ?? ""}
|
||||
onChange={(value) =>
|
||||
setForm((current) => ({ ...current, windowCloseHour: value }))
|
||||
}
|
||||
clampBehavior="none"
|
||||
allowDecimal
|
||||
min={0}
|
||||
max={23}
|
||||
disabled={loading}
|
||||
/>
|
||||
<DurationField
|
||||
label="Window duration"
|
||||
description="How long the import booking window stays open"
|
||||
|
||||
@@ -58,6 +58,7 @@ import type {
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
UpdateScheduleWindowRulePayload,
|
||||
TrainSchedulePreviewPayload,
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainTrackResponse,
|
||||
@@ -438,6 +439,30 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
updateScheduleWindowRule: endpoint<
|
||||
{ id: string; payload: UpdateScheduleWindowRulePayload },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-schedule-window-rule",
|
||||
({ id, payload }) =>
|
||||
trainSchedulingService.updateScheduleWindowRule(id, payload),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
updateScheduleDate: endpoint<
|
||||
{ id: string; scheduleDate: string },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"update-schedule-date",
|
||||
({ id, scheduleDate }) =>
|
||||
trainSchedulingService.updateScheduleDate(id, scheduleDate),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
markBookingPaid: endpoint<string, void>(
|
||||
"train-scheduling",
|
||||
"mark-booking-paid",
|
||||
@@ -521,6 +546,26 @@ export const api = {
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
setLoadingStatus: endpoint<
|
||||
{ id: string; bookingIds: string[]; loadingStatus: LoadingStatus },
|
||||
TrainScheduleDetail
|
||||
>(
|
||||
"train-scheduling",
|
||||
"set-loading-status",
|
||||
({ id, bookingIds, loadingStatus }) =>
|
||||
trainSchedulingService.setLoadingStatus(id, { bookingIds, loadingStatus }),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
confirmLoading: endpoint<{ id: string }, TrainScheduleDetail>(
|
||||
"train-scheduling",
|
||||
"confirm-loading",
|
||||
({ id }) => trainSchedulingService.confirmLoading(id),
|
||||
undefined,
|
||||
() => TRAIN_SCHEDULING_INVALIDATIONS,
|
||||
),
|
||||
|
||||
pinWagons: endpoint<
|
||||
{ id: string; payload: PinWagonsPayload },
|
||||
TrainScheduleDetail
|
||||
|
||||
@@ -391,35 +391,6 @@ export const contractsService = {
|
||||
return unwrap(response.data) as Freight.ClearanceT1State;
|
||||
},
|
||||
|
||||
/** Train schedules carrying customs bookings — GL DJ gate-pass table. */
|
||||
getDjClearanceSchedules: async (): Promise<Freight.DjClearanceSchedule[]> => {
|
||||
const response = await client.get(C.CLEARANCE_DJ_SCHEDULES);
|
||||
return unwrap(response.data) as Freight.DjClearanceSchedule[];
|
||||
},
|
||||
|
||||
/** Gate pass for every customs booking on a train schedule (captures time). */
|
||||
grantScheduleGatepass: async (
|
||||
scheduleId: string,
|
||||
gatepassAt?: string,
|
||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> => {
|
||||
const response = await client.post(C.CLEARANCE_SCHEDULE_GATEPASS(scheduleId), {
|
||||
gatepassAt,
|
||||
});
|
||||
return unwrap(response.data) as {
|
||||
granted: number;
|
||||
skipped: Array<{ bookingId: string; error: string }>;
|
||||
};
|
||||
},
|
||||
|
||||
/** Gate pass for a single customs booking (captures time). */
|
||||
grantGatepass: async (
|
||||
bookingId: string,
|
||||
gatepassAt?: string,
|
||||
): Promise<{ bookingId: string; gatepassAt: string }> => {
|
||||
const response = await client.post(C.BOOKING_GATEPASS(bookingId), { gatepassAt });
|
||||
return unwrap(response.data) as { bookingId: string; gatepassAt: string };
|
||||
},
|
||||
|
||||
/** GL DJ raises the post-offload final invoice (amount + invoice document). */
|
||||
sendFinalInvoice: async (
|
||||
bookingId: string,
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
RecordCheckpointPayload,
|
||||
StaffBookingWindow,
|
||||
TrainScheduleDetail,
|
||||
UpdateScheduleWindowRulePayload,
|
||||
TrainScheduleFilters,
|
||||
TrainScheduleListItem,
|
||||
TrainSchedulePreviewPayload,
|
||||
@@ -210,6 +211,28 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateScheduleWindowRule: async (
|
||||
scheduleId: string,
|
||||
payload: UpdateScheduleWindowRulePayload,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.patch<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.WINDOW_RULE(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateScheduleDate: async (
|
||||
scheduleId: string,
|
||||
scheduleDate: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.patch<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.SCHEDULE_DATE(scheduleId),
|
||||
{ scheduleDate },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markBookingPaid: async (bookingId: string): Promise<void> => {
|
||||
await client.post(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARK_BOOKING_PAID(bookingId),
|
||||
@@ -335,6 +358,27 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
setLoadingStatus: async (
|
||||
scheduleId: string,
|
||||
payload: { bookingIds: string[]; loadingStatus: LoadingStatus },
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.patch<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.LOADING_STATUS(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
confirmLoading: async (
|
||||
scheduleId: string,
|
||||
): Promise<TrainScheduleDetail> => {
|
||||
const response = await client.post<TrainScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CONFIRM_LOADING(scheduleId),
|
||||
{},
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getImportDjiboutiOperation: async (
|
||||
scheduleId: string,
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
|
||||
@@ -108,6 +108,7 @@ export interface TrainSchedulingGlobalRules {
|
||||
importWindowLeadDays: number;
|
||||
exportBookingLeadHours: number;
|
||||
windowOpenHour: number;
|
||||
windowCloseHour: number;
|
||||
windowDurationHours: number;
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
@@ -399,6 +400,28 @@ export interface TrainScheduleWagonAllocation {
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** Per-schedule booking-window rule snapshot (null fields fall back to global config). */
|
||||
export interface ScheduleWindowRule {
|
||||
windowOpenHour: number | null;
|
||||
windowCloseHour: number | null;
|
||||
windowDurationHours: number | null;
|
||||
reopenDelayMinutes: number | null;
|
||||
importWindowLeadDays: number | null;
|
||||
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */
|
||||
docReviewMinutes: number;
|
||||
paymentWindowMinutes: number;
|
||||
}
|
||||
|
||||
/** Editable window-rule override for one schedule; every field optional. */
|
||||
export interface UpdateScheduleWindowRulePayload {
|
||||
windowOpenHour?: number;
|
||||
windowCloseHour?: number;
|
||||
windowDurationHours?: number;
|
||||
docReviewMinutes?: number;
|
||||
paymentWindowMinutes?: number;
|
||||
importWindowLeadDays?: number;
|
||||
}
|
||||
|
||||
export interface TrainScheduleDetail {
|
||||
id: string;
|
||||
status: TrainScheduleStatus | string;
|
||||
@@ -406,11 +429,17 @@ export interface TrainScheduleDetail {
|
||||
freightType?: FreightType | null;
|
||||
trainNumber?: string | null;
|
||||
direction?: string | null;
|
||||
/** True when this schedule needs loading confirmed before dispatch (import-Djibouti). */
|
||||
requiresLoadingConfirmation?: boolean;
|
||||
/** True when loading is already confirmed (or not required for this direction). */
|
||||
loadingConfirmed?: boolean;
|
||||
windowPhase?: BookingWindowPhase | string | null;
|
||||
windowOpensAt?: string | null;
|
||||
windowClosesAt?: string | null;
|
||||
docReviewEndsAt?: string | null;
|
||||
paymentPhaseEndsAt?: string | null;
|
||||
/** Booking-window rule snapshot — prefills the per-schedule settings editor. */
|
||||
windowRule?: ScheduleWindowRule | null;
|
||||
route?: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -479,6 +508,8 @@ export interface TrainScheduleDetail {
|
||||
status: string | null;
|
||||
schedulingStatus?: SchedulingStatus | null;
|
||||
freightType?: FreightType | string | null;
|
||||
loadingStatus?: "LOADED" | "UNLOADED";
|
||||
wagonAssigned?: boolean;
|
||||
}>;
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
"react-phone-number-input": "^3.4.17",
|
||||
"react-router-dom": "^6.27.0",
|
||||
"recharts": "^3.8.1",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.0"
|
||||
|
||||
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 158 KiB |
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
BIN
apps/edr-freight-web/portal/public/assets/edr_image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 861 KiB |
@@ -5,6 +5,7 @@ import {
|
||||
Layers,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Package,
|
||||
Receipt,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
@@ -35,6 +36,7 @@ import InvoiceDetailPage from "./pages/billing/InvoiceDetailPage";
|
||||
import InvoicesList from "./pages/billing/InvoicesList";
|
||||
import BookingContractPage from "./pages/bookings/BookingContractPage";
|
||||
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
|
||||
import BookingsListPage from "./pages/bookings/BookingsListPage";
|
||||
import EditBookingPage from "./pages/bookings/EditBookingPage";
|
||||
import ContractClearanceFlow from "./pages/contracts/ContractClearanceFlow";
|
||||
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
|
||||
@@ -184,6 +186,11 @@ const sidebarItems: SidebarItem[] = [
|
||||
href: "/contracts",
|
||||
icon: <Layers size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Bookings",
|
||||
href: "/bookings",
|
||||
icon: <Package size={18} />,
|
||||
},
|
||||
{
|
||||
label: "Tracking",
|
||||
href: "/tracking",
|
||||
@@ -255,12 +262,9 @@ const App = () => {
|
||||
}
|
||||
>
|
||||
<Route path="/portal" element={<MyPortalPage />} />
|
||||
{/* Bookings live under contracts now — the standalone list is gone.
|
||||
Legacy /bookings* entry points redirect into the contract flow. */}
|
||||
<Route
|
||||
path="/bookings"
|
||||
element={<Navigate to="/contracts" replace />}
|
||||
/>
|
||||
{/* Bookings are created against a contract, but the full list is
|
||||
browsable here. New-booking entry still routes via a contract. */}
|
||||
<Route path="/bookings" element={<BookingsListPage />} />
|
||||
<Route
|
||||
path="/bookings/new"
|
||||
element={<Navigate to="/contracts/new" replace />}
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
Bell,
|
||||
ChevronDown,
|
||||
FileSignature,
|
||||
LogOut,
|
||||
@@ -40,6 +39,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
|
||||
import NotificationBellContainer from "@/features/notifications/NotificationBellContainer";
|
||||
|
||||
export interface SidebarItem {
|
||||
label: string;
|
||||
@@ -353,25 +353,8 @@ export function AppLayout({
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* Bell */}
|
||||
<Box style={{ position: "relative" }}>
|
||||
<UnstyledButton style={islandStyle} aria-label="Notifications">
|
||||
<Bell size={17} color={textColor} strokeWidth={1.8} />
|
||||
</UnstyledButton>
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 7,
|
||||
right: 7,
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: accentColor,
|
||||
border: "1.5px solid #fff",
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{/* Notifications */}
|
||||
<NotificationBellContainer />
|
||||
|
||||
{enableThemeToggle && (
|
||||
<UnstyledButton
|
||||
|
||||
@@ -1,40 +1,25 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowUpRight, ChevronDown, Globe } from "lucide-react";
|
||||
import { Box, Image, Stack, Text, Title } from "@mantine/core";
|
||||
import { ChevronDown, Globe } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const LOGIN_IMAGE = "/assets/login.png";
|
||||
const EDR_IMAGE = "/assets/edr_image.png";
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
|
||||
/** Muted deep-green brand wash for the left panel. */
|
||||
const LEFT_PANEL_BG =
|
||||
"linear-gradient(158deg, #2E6B55 0%, #21503F 46%, #16352A 100%)";
|
||||
|
||||
/** Radial opacity mask: image fully opaque at its center, fading to nothing at the edges. */
|
||||
const IMAGE_FADE_MASK =
|
||||
"linear-gradient(-90deg, #000 95%, #0009 97%, #0000 100%), linear-gradient(0deg, #000 80%, #0001 100%)";
|
||||
|
||||
export const fieldClass =
|
||||
"h-11 w-full rounded-xl border border-gray-200/90 bg-white px-4 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 outline-none transition-all duration-200 hover:border-gray-300 focus:border-primary focus:bg-white focus:ring-4 focus:ring-primary/10";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"h-11 w-full rounded-full bg-primary text-sm font-semibold text-primary-foreground shadow-[0_8px_20px_-6px_rgba(16,94,52,0.5)] transition-all duration-200 hover:bg-primary/90 hover:shadow-[0_10px_24px_-6px_rgba(16,94,52,0.55)] active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-60 disabled:shadow-none";
|
||||
|
||||
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"
|
||||
@@ -63,7 +48,7 @@ const RightPanelDecor = () => (
|
||||
|
||||
export interface AuthShellProps {
|
||||
children: ReactNode;
|
||||
/** Tagline shown in the highlighted card over the left image panel. */
|
||||
/** Headline shown in the top-left of the green panel. */
|
||||
tagline?: string;
|
||||
taglineBody?: string;
|
||||
}
|
||||
@@ -72,45 +57,63 @@ const LeftPanel = ({
|
||||
tagline,
|
||||
taglineBody,
|
||||
}: Pick<AuthShellProps, "tagline" | "taglineBody">) => (
|
||||
<div className="relative hidden shrink-0 flex-col overflow-hidden rounded-2xl bg-[#011F12] shadow-[0_8px_32px_rgba(15,23,42,0.1)] lg:flex 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-auto w-full object-cover object-center"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-[#0a2e1a]/92 via-[#0f4a2a]/15 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
|
||||
<Box
|
||||
className="relative hidden shrink-0 overflow-hidden rounded-2xl shadow-[0_8px_32px_rgba(15,23,42,0.12)] lg:flex lg:h-auto lg:min-h-0 lg:flex-1 lg:basis-1/2 lg:rounded-[28px]"
|
||||
style={{ background: LEFT_PANEL_BG }}
|
||||
>
|
||||
{/* Top-left: logo, title, description — stacked, left aligned. */}
|
||||
<Stack
|
||||
gap="xl"
|
||||
className="relative z-10 p-8 lg:p-10"
|
||||
style={{ maxWidth: 520 }}
|
||||
>
|
||||
<Image
|
||||
src={EDR_LOGO}
|
||||
alt="EDR Freight"
|
||||
className="h-7 w-auto brightness-0 invert sm:h-9"
|
||||
h={40}
|
||||
w="auto"
|
||||
fit="contain"
|
||||
style={{ filter: "brightness(0) invert(1)", alignSelf: "flex-start" }}
|
||||
/>
|
||||
<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">
|
||||
{tagline ?? "Empower Your Freight Operations"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-white/85">
|
||||
<Stack gap="sm">
|
||||
<Title
|
||||
order={1}
|
||||
c="white"
|
||||
fz={38}
|
||||
fw={800}
|
||||
lh={1.08}
|
||||
style={{ letterSpacing: "-0.02em" }}
|
||||
>
|
||||
{tagline ?? "Ethiopian Djibouti Railway"}
|
||||
</Title>
|
||||
<Text fz="md" lh={1.6} c="rgba(255,255,255,0.82)" maw={440}>
|
||||
{taglineBody ??
|
||||
"Sign in to manage shipments, track cargo, and run logistics operations on the Ethio Djibouti Railway freight platform."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"Sign in to book shipments, track cargo, and manage your freight on the Ethio–Djibouti Railway platform."}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Bottom-right: brand image with a center-to-edge opacity fade, no color tint. */}
|
||||
<Image
|
||||
src={EDR_IMAGE}
|
||||
alt=""
|
||||
aria-hidden
|
||||
fit="cover"
|
||||
pos="absolute"
|
||||
right={0}
|
||||
bottom={0}
|
||||
w="80%"
|
||||
h="60%"
|
||||
style={{
|
||||
WebkitMaskImage: IMAGE_FADE_MASK,
|
||||
maskImage: IMAGE_FADE_MASK,
|
||||
opacity: 0.9,
|
||||
pointerEvents: "none",
|
||||
maskComposite: "intersect",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const LanguageSelector = () => (
|
||||
@@ -125,24 +128,24 @@ 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="#"
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Terms & Conditions
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
</Link>
|
||||
<Link
|
||||
to="#"
|
||||
className="font-semibold text-gray-700 transition-colors hover:text-primary"
|
||||
>
|
||||
Help & Support
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -169,8 +172,8 @@ export default function AuthShell({
|
||||
</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 max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9 lg:py-9">
|
||||
<div className="flex min-h-full justify-center">
|
||||
<div className="my-auto w-full max-w-xl rounded-3xl px-5 py-6 sm:px-7 sm:py-8 lg:px-9">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { AlertCircle, CheckCircle2, Download } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, Download, Info } from "lucide-react";
|
||||
import { useETradeData } from "@/hooks/useETradeData";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
|
||||
interface ETradeInfoProps {
|
||||
@@ -22,6 +24,8 @@ interface ETradeInfoProps {
|
||||
onDataLoaded: (data: CompanyRegistrationData) => void;
|
||||
}
|
||||
|
||||
const isValidTin = (tin: string) => tin.length === 10;
|
||||
|
||||
export default function ETradeInfo({
|
||||
tin,
|
||||
register,
|
||||
@@ -30,53 +34,100 @@ export default function ETradeInfo({
|
||||
}: ETradeInfoProps) {
|
||||
const mutation = useETradeData();
|
||||
const isLoading = mutation.isPending;
|
||||
const hasData = mutation.data;
|
||||
const tinTaken = mutation.data?.tinTaken;
|
||||
const hasData =
|
||||
mutation.data && !mutation.data.tinTaken ? mutation.data : null;
|
||||
|
||||
const handleFetch = async () => {
|
||||
if (!tin || tin.length !== 10 || !tin.startsWith("00")) return;
|
||||
if (!isValidTin(tin)) return;
|
||||
const result = await mutation.mutateAsync(tin);
|
||||
if (result) {
|
||||
if (result && !result.tinTaken) {
|
||||
onDataLoaded(result);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage =
|
||||
// Auto-fetch as soon as the TIN reaches its full 10-digit length — only
|
||||
// once per distinct value, so retyping the same TIN doesn't refetch.
|
||||
const lastFetchedTin = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (isValidTin(tin) && lastFetchedTin.current !== tin) {
|
||||
lastFetchedTin.current = tin;
|
||||
handleFetch();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tin]);
|
||||
|
||||
const apiError =
|
||||
mutation.isError && mutation.error
|
||||
? (mutation.error as any).message ||
|
||||
"Failed to fetch company information. Please try again."
|
||||
? extractApiError(mutation.error)
|
||||
: null;
|
||||
// A 400 here means eTrade simply has no record for this TIN — not a
|
||||
// failure. Soft-pedal it as an FYI, not a red error, so filling in
|
||||
// manually doesn't feel like something went wrong.
|
||||
const notFound = apiError?.statusCode === 400;
|
||||
const errorMessage =
|
||||
apiError && !notFound
|
||||
? apiError.message ||
|
||||
"We couldn't reach eTrade to fetch your company information. Please try again, or fill in the details manually below."
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group align="flex-start" grow>
|
||||
<TextInput
|
||||
label={<>TIN Number (10 digits) <span style={{ color: "var(--mantine-color-red-6)" }}>*</span></>}
|
||||
label={
|
||||
<>
|
||||
TIN Number (10 digits){" "}
|
||||
<span style={{ color: "var(--mantine-color-red-6)" }}>*</span>
|
||||
</>
|
||||
}
|
||||
placeholder="0012345678"
|
||||
maxLength={10}
|
||||
error={error}
|
||||
{...register}
|
||||
/>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!tin || tin.length !== 10 || !tin.startsWith("00") || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
{errorMessage && (
|
||||
<Button
|
||||
variant="filled"
|
||||
color="edr-green"
|
||||
onClick={handleFetch}
|
||||
disabled={!isValidTin(tin) || isLoading}
|
||||
leftSection={
|
||||
isLoading ? <Loader size={16} /> : <Download size={16} />
|
||||
}
|
||||
mt="24px"
|
||||
>
|
||||
{isLoading ? "Getting..." : "Get Data"}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{notFound && (
|
||||
<Alert icon={<Info size={16} />} color="gray">
|
||||
We couldn't find a matching business record for this TIN — no
|
||||
problem, just fill in the details below.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="Failed to fetch data"
|
||||
title="Couldn't fetch eTrade data"
|
||||
>
|
||||
{errorMessage} You can still fill in the details manually below.
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{tinTaken && (
|
||||
<Alert
|
||||
icon={<AlertCircle size={16} />}
|
||||
color="red"
|
||||
title="TIN already registered"
|
||||
>
|
||||
This TIN is already registered to another company account. Please
|
||||
double-check the number, or contact support if you believe this is a
|
||||
mistake.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ interface RoleLicenseStepProps {
|
||||
/** Newly-selected files per profile id (not yet uploaded). */
|
||||
value: Record<string, File[]>;
|
||||
onChange: (value: Record<string, File[]>) => void;
|
||||
/** "Business license is required" style error, keyed by profile id. */
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,7 @@ export default function RoleLicenseStep({
|
||||
profiles,
|
||||
value,
|
||||
onChange,
|
||||
errors,
|
||||
}: RoleLicenseStepProps) {
|
||||
const setFiles = (profileId: string, files: File[]) => {
|
||||
onChange({ ...value, [profileId]: files });
|
||||
@@ -123,6 +126,11 @@ export default function RoleLicenseStep({
|
||||
file={buildLicenseSetting(profile.id, label)}
|
||||
value={{ [LICENSE_FILE_KEY]: selected }}
|
||||
uploadedKeys={hasExisting ? [LICENSE_FILE_KEY] : undefined}
|
||||
errors={
|
||||
errors?.[profile.id]
|
||||
? { [LICENSE_FILE_KEY]: errors[profile.id] }
|
||||
: undefined
|
||||
}
|
||||
onChange={(v) => {
|
||||
const next = v[LICENSE_FILE_KEY];
|
||||
const files = Array.isArray(next) ? next : next ? [next] : [];
|
||||
|
||||
@@ -14,6 +14,7 @@ export const URL_CONSTANTS = {
|
||||
SET_PASSWORD: "/api/auth/set-password",
|
||||
ME: "/api/auth/me",
|
||||
GENERATE_VERIFICATION_CODE: "/users/generate-verification-code",
|
||||
CHECK_AVAILABILITY: "/api/auth/check-availability",
|
||||
},
|
||||
|
||||
OTP: {
|
||||
@@ -106,6 +107,9 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
||||
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
|
||||
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
|
||||
},
|
||||
|
||||
CONTRACTS: {
|
||||
@@ -172,5 +176,6 @@ export const URL_CONSTANTS = {
|
||||
BY_ID: (id: string) => `/api/warehouse-fee-invoices/${id}`,
|
||||
DOCUMENT: (id: string) => `/api/warehouse-fee-invoices/${id}/document`,
|
||||
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
|
||||
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { NotificationDto, NotificationListResult } from "@edr/types";
|
||||
import {
|
||||
NotificationBell,
|
||||
NotificationDrawer,
|
||||
NotificationToast,
|
||||
type NotificationItemData,
|
||||
} from "@edr/ui-common";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import {
|
||||
resolveNotificationHref,
|
||||
resolveNotificationVisual,
|
||||
} from "./notificationConfig";
|
||||
import {
|
||||
useInfiniteNotifications,
|
||||
useMarkAllRead,
|
||||
useMarkRead,
|
||||
useUnreadCount,
|
||||
} from "./useNotifications";
|
||||
import { useNotificationSocket } from "./useNotificationSocket";
|
||||
|
||||
/** Map a server notification into the shared presentational item shape. */
|
||||
function toItem(n: NotificationDto): NotificationItemData {
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
title: n.title,
|
||||
body: n.body,
|
||||
createdAt: n.createdAt,
|
||||
isRead: n.isRead,
|
||||
priority: n.priority,
|
||||
link: n.link,
|
||||
data: n.data,
|
||||
};
|
||||
}
|
||||
|
||||
/** Flatten an infinite query's pages into the drawer's item shape. */
|
||||
function toItems(
|
||||
data: { pages: NotificationListResult[] } | undefined,
|
||||
): NotificationItemData[] {
|
||||
return (data?.pages ?? []).flatMap((p) => p.items).map(toItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires react-query (infinite unread/read lists) + the notification WebSocket
|
||||
* into the shared bell + drawer. Lists are only fetched while the drawer is
|
||||
* open; the badge is driven by the lightweight unread-count query + socket.
|
||||
*/
|
||||
export default function NotificationBellContainer({
|
||||
enabled = true,
|
||||
}: {
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const unreadQ = useInfiniteNotifications(false, enabled && opened);
|
||||
const readQ = useInfiniteNotifications(true, enabled && opened);
|
||||
const unread = useUnreadCount(enabled);
|
||||
const markRead = useMarkRead();
|
||||
const markAllRead = useMarkAllRead();
|
||||
|
||||
const unreadItems = toItems(unreadQ.data);
|
||||
const readItems = toItems(readQ.data);
|
||||
const unreadCount = unread.data ?? 0;
|
||||
|
||||
const handleItemClick = (item: NotificationItemData) => {
|
||||
if (!item.isRead) markRead.mutate(item.id);
|
||||
const href = resolveNotificationHref(item);
|
||||
setOpened(false);
|
||||
if (href) navigate(href);
|
||||
};
|
||||
|
||||
// Live push → rich toast that reuses the same registry + click action.
|
||||
useNotificationSocket(enabled, (n) => {
|
||||
const item = toItem(n);
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<NotificationToast
|
||||
item={item}
|
||||
visual={resolveNotificationVisual(item)}
|
||||
visible={t.visible}
|
||||
onClick={() => {
|
||||
toast.dismiss(t.id);
|
||||
handleItemClick(item);
|
||||
}}
|
||||
onDismiss={() => toast.dismiss(t.id)}
|
||||
/>
|
||||
),
|
||||
{ duration: 6000 },
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<NotificationBell
|
||||
unreadCount={unreadCount}
|
||||
onClick={() => setOpened(true)}
|
||||
/>
|
||||
<NotificationDrawer
|
||||
opened={opened}
|
||||
onClose={() => setOpened(false)}
|
||||
unread={unreadItems}
|
||||
read={readItems}
|
||||
unreadCount={unreadCount}
|
||||
loading={opened && (unreadQ.isLoading || readQ.isLoading)}
|
||||
hasMoreUnread={unreadQ.hasNextPage}
|
||||
hasMoreRead={readQ.hasNextPage}
|
||||
loadingMoreUnread={unreadQ.isFetchingNextPage}
|
||||
loadingMoreRead={readQ.isFetchingNextPage}
|
||||
onLoadMoreUnread={() => {
|
||||
if (unreadQ.hasNextPage && !unreadQ.isFetchingNextPage) {
|
||||
void unreadQ.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onLoadMoreRead={() => {
|
||||
if (readQ.hasNextPage && !readQ.isFetchingNextPage) {
|
||||
void readQ.fetchNextPage();
|
||||
}
|
||||
}}
|
||||
onItemClick={handleItemClick}
|
||||
onMarkAllRead={() => markAllRead.mutate()}
|
||||
resolveVisual={resolveNotificationVisual}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NotificationType } from "@edr/types";
|
||||
import type { NotificationItemData, NotificationVisual } from "@edr/ui-common";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Bell,
|
||||
FileWarning,
|
||||
Package,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
|
||||
const ICON_SIZE = 17;
|
||||
|
||||
/**
|
||||
* Portal notification registry. Maps a notification `type` → icon + Mantine
|
||||
* color, and `type`/`data` → an in-app deep link. This is the single place to
|
||||
* customize how each notification looks and where clicking it goes.
|
||||
*/
|
||||
export function resolveNotificationVisual(
|
||||
item: NotificationItemData,
|
||||
): NotificationVisual {
|
||||
switch (item.type) {
|
||||
case NotificationType.CLEARANCE_DECISION:
|
||||
return { icon: <BadgeCheck size={ICON_SIZE} />, color: "teal" };
|
||||
case NotificationType.DOCUMENT_ACTION:
|
||||
return { icon: <FileWarning size={ICON_SIZE} />, color: "orange" };
|
||||
case NotificationType.BOOKING_STATUS:
|
||||
return { icon: <Package size={ICON_SIZE} />, color: "blue" };
|
||||
case NotificationType.INVOICE_ISSUED:
|
||||
return { icon: <Receipt size={ICON_SIZE} />, color: "violet" };
|
||||
default:
|
||||
return { icon: <Bell size={ICON_SIZE} />, color: "edr-green" };
|
||||
}
|
||||
}
|
||||
|
||||
function asId(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve where clicking a notification navigates. Prefers an explicit
|
||||
* server-provided `link`, else derives a route from `type` + `data`.
|
||||
* Returns `null` when there's nowhere sensible to go (item just marks read).
|
||||
*/
|
||||
export function resolveNotificationHref(
|
||||
item: NotificationItemData,
|
||||
): string | null {
|
||||
if (item.link) return item.link;
|
||||
const data = item.data ?? {};
|
||||
switch (item.type) {
|
||||
case NotificationType.INVOICE_ISSUED: {
|
||||
const id = asId(data.invoiceId);
|
||||
return id ? `/billing/${id}` : "/billing";
|
||||
}
|
||||
case NotificationType.BOOKING_STATUS: {
|
||||
const id = asId(data.bookingId);
|
||||
return id ? `/bookings/${id}` : null;
|
||||
}
|
||||
case NotificationType.CLEARANCE_DECISION:
|
||||
case NotificationType.DOCUMENT_ACTION: {
|
||||
const id = asId(data.contractId);
|
||||
return id ? `/contracts/${id}` : "/contracts";
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NotificationListResult } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
export interface ListNotificationsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
isRead?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Portal notification REST calls. The portal axios `client` returns the raw
|
||||
* response, and the API wraps payloads in a `{ success, data }` envelope — so we
|
||||
* unwrap `.data.data` here (same convention as the other portal services).
|
||||
*/
|
||||
export const notificationsApi = {
|
||||
list: async (
|
||||
params: ListNotificationsParams = {},
|
||||
): Promise<NotificationListResult> => {
|
||||
const { data } = await client.get("/api/notifications", { params });
|
||||
return data.data;
|
||||
},
|
||||
unreadCount: async (): Promise<number> => {
|
||||
const { data } = await client.get("/api/notifications/unread-count");
|
||||
return data.data.unreadCount;
|
||||
},
|
||||
markRead: async (id: string): Promise<void> => {
|
||||
await client.patch(`/api/notifications/${id}/read`);
|
||||
},
|
||||
markAllRead: async (): Promise<void> => {
|
||||
await client.post("/api/notifications/read-all");
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
NOTIFICATION_WS_EVENTS,
|
||||
NOTIFICATION_WS_NAMESPACE,
|
||||
type NotificationDto,
|
||||
} from "@edr/types";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { io } from "socket.io-client";
|
||||
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
import { NOTIFICATIONS_KEY, UNREAD_KEY } from "./useNotifications";
|
||||
|
||||
function getAuthToken(): string | undefined {
|
||||
return document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("auth-token="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
// The socket namespace lives at the server root, not under the `/api` REST
|
||||
// prefix — strip a trailing `/api` if the base URL carries one.
|
||||
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
|
||||
|
||||
/**
|
||||
* Subscribes to live notification pushes for the signed-in user. New items
|
||||
* invalidate the cached lists + fire `onNew` (the host shows a rich toast);
|
||||
* unread-count pushes update the badge instantly.
|
||||
*/
|
||||
export function useNotificationSocket(
|
||||
enabled: boolean,
|
||||
onNew?: (notification: NotificationDto) => void,
|
||||
) {
|
||||
const qc = useQueryClient();
|
||||
const onNewRef = useRef(onNew);
|
||||
onNewRef.current = onNew;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const token = getAuthToken();
|
||||
if (!token) return;
|
||||
|
||||
const socket = io(`${SOCKET_ORIGIN}/${NOTIFICATION_WS_NAMESPACE}`, {
|
||||
auth: { token },
|
||||
transports: ["websocket"],
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.NEW, (n: NotificationDto) => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
onNewRef.current?.(n);
|
||||
});
|
||||
|
||||
socket.on(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, (count: number) => {
|
||||
if (typeof count === "number") qc.setQueryData(UNREAD_KEY, count);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off();
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [enabled, qc]);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { notificationsApi } from "./notificationsApi";
|
||||
|
||||
export const NOTIFICATIONS_KEY = ["notifications"] as const;
|
||||
export const UNREAD_KEY = ["notifications", "unread"] as const;
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Paginated (infinite) notifications for one read-state. Drives a drawer
|
||||
* section; call `fetchNextPage` as the user scrolls. Each page carries the
|
||||
* server `count` so we know when to stop.
|
||||
*/
|
||||
export function useInfiniteNotifications(isRead: boolean, enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: [...NOTIFICATIONS_KEY, "list", { isRead }],
|
||||
queryFn: ({ pageParam }) =>
|
||||
notificationsApi.list({ page: pageParam, limit: PAGE_SIZE, isRead }),
|
||||
initialPageParam: 1,
|
||||
getNextPageParam: (lastPage, allPages) => {
|
||||
const loaded = allPages.reduce((sum, p) => sum + p.items.length, 0);
|
||||
return loaded < lastPage.count ? allPages.length + 1 : undefined;
|
||||
},
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: UNREAD_KEY,
|
||||
queryFn: () => notificationsApi.unreadCount(),
|
||||
enabled,
|
||||
// WebSocket keeps this fresh; poll as a fallback if the socket drops.
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => notificationsApi.markRead(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllRead() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => notificationsApi.markAllRead(),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: NOTIFICATIONS_KEY });
|
||||
qc.invalidateQueries({ queryKey: UNREAD_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import "@mantine/dates/styles.css";
|
||||
import "@edr/ui-common/styles.css";
|
||||
import "../index.css";
|
||||
import "@edr/ui-common/theme.css";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { mantineTheme } from "./theme/mantine";
|
||||
|
||||
import App from "./App";
|
||||
@@ -39,6 +40,7 @@ createRoot(document.getElementById("root")!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</MantineProvider>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight, UserCheck } from "lucide-react";
|
||||
import { AlertCircle, ArrowLeft, ArrowRight } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type { CompanyRegistrationData } from "@edr/types";
|
||||
import { ControlledPhoneField, toEthiopianE164 } from "@/components/PhoneField";
|
||||
import { SmartFileInput } from "@edr/ui-common";
|
||||
import { getMinFiles } from "@/types/fileUploadSettings";
|
||||
import { api } from "@/services/api";
|
||||
import RoleLicenseStep, {
|
||||
type RoleLicenseProfile,
|
||||
@@ -279,22 +280,39 @@ export default function CompanyProfileForm({
|
||||
});
|
||||
};
|
||||
|
||||
/** Fill the General Manager from the eTrade business owner. */
|
||||
const useOwnerAsManager = () => {
|
||||
if (!etradeOwner) return;
|
||||
setValue("generalManagerName", etradeOwner.name);
|
||||
setValue("generalManagerEmail", user.email);
|
||||
setValue("generalManagerPhone", etradeOwner.phone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
// "Same as …" links. A checked card prefills the target step's fields from the
|
||||
// source step and disables them (kept mirrored while linked); unchecking clears
|
||||
// them and re-enables editing.
|
||||
const [gmSameAsOwner, setGmSameAsOwner] = useState(false);
|
||||
const [contactSameAsGm, setContactSameAsGm] = useState(false);
|
||||
const [poaSameAsContact, setPoaSameAsContact] = useState(false);
|
||||
|
||||
// General Manager source: the eTrade-registered business owner when a TIN
|
||||
// lookup found one, otherwise the registering user's own account details.
|
||||
const gmSourceName = etradeOwner?.name ?? user.name?.en ?? "";
|
||||
const gmSourcePhone = etradeOwner
|
||||
? etradeOwner.phone
|
||||
: toEthiopianE164(user.phoneNumber);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmSameAsOwner) return;
|
||||
setValue("generalManagerName", gmSourceName, { shouldValidate: true });
|
||||
setValue("generalManagerEmail", user.email ?? "", { shouldValidate: true });
|
||||
setValue("generalManagerPhone", gmSourcePhone ?? "", {
|
||||
shouldValidate: true,
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gmSameAsOwner, gmSourceName, gmSourcePhone, user.email]);
|
||||
|
||||
const toggleGmSameAsOwner = (checked: boolean) => {
|
||||
setGmSameAsOwner(checked);
|
||||
if (!checked) {
|
||||
setValue("generalManagerName", "");
|
||||
setValue("generalManagerEmail", "");
|
||||
setValue("generalManagerPhone", "");
|
||||
}
|
||||
};
|
||||
|
||||
const gmName = watch("generalManagerName");
|
||||
const gmEmail = watch("generalManagerEmail");
|
||||
const gmPhone = watch("generalManagerPhone");
|
||||
@@ -341,6 +359,72 @@ export default function CompanyProfileForm({
|
||||
|
||||
const hasDocuments = Boolean(uploadSetting?.fields?.length);
|
||||
|
||||
// Hard verification for the documents step: required company-level
|
||||
// documents and a business license per operational profile must both be
|
||||
// present before the user can continue.
|
||||
const [documentFieldErrors, setDocumentFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [licenseFieldErrors, setLicenseFieldErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const validateRequiredDocuments = (): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const field of uploadSetting?.fields ?? []) {
|
||||
const min = getMinFiles(field);
|
||||
if (min <= 0) continue;
|
||||
if ((uploadedDocumentKeys ?? []).includes(field.fileKey)) continue;
|
||||
const v = documentFiles[field.fileKey];
|
||||
const count = Array.isArray(v) ? v.length : v ? 1 : 0;
|
||||
if (count < min) {
|
||||
errs[field.fileKey] = `${field.fileLabel} is required`;
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
};
|
||||
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const validateLicenses = (): Record<string, string> => {
|
||||
const errs: Record<string, string> = {};
|
||||
for (const p of roleProfiles ?? []) {
|
||||
const hasNew = (licenseFiles?.[p.id]?.length ?? 0) > 0;
|
||||
const hasExisting = p.existingFiles.length > 0;
|
||||
if (!hasNew && !hasExisting) {
|
||||
errs[p.id] = "Business license is required";
|
||||
}
|
||||
}
|
||||
return errs;
|
||||
};
|
||||
|
||||
const handleDocumentFilesChange = (
|
||||
next: Record<string, File | File[] | null>,
|
||||
) => {
|
||||
setDocumentFiles(next);
|
||||
setDocumentFieldErrors((prev) => {
|
||||
if (Object.keys(prev).length === 0) return prev;
|
||||
const updated = { ...prev };
|
||||
for (const key of Object.keys(updated)) {
|
||||
const v = next[key];
|
||||
const hasValue = Array.isArray(v) ? v.length > 0 : v != null;
|
||||
if (hasValue) delete updated[key];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleLicenseFilesChange = (next: Record<string, File[]>) => {
|
||||
onLicenseChange?.(next);
|
||||
setLicenseFieldErrors((prev) => {
|
||||
if (Object.keys(prev).length === 0) return prev;
|
||||
const updated = { ...prev };
|
||||
for (const id of Object.keys(updated)) {
|
||||
if ((next[id]?.length ?? 0) > 0) delete updated[id];
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// The registration/license details come straight from the eTrade lookup and
|
||||
// are not user-editable — shown as a read-only confirmation once a TIN lookup
|
||||
// (or rehydration) has filled them in. The address fields below are separate:
|
||||
@@ -385,18 +469,21 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Every role needs at least one license file (existing or newly selected).
|
||||
const licenseComplete = (roleProfiles ?? []).every(
|
||||
(p) =>
|
||||
(licenseFiles?.[p.id]?.length ?? 0) > 0 || p.existingFiles.length > 0,
|
||||
);
|
||||
|
||||
const nextStep = async () => {
|
||||
userNavigatedRef.current = true;
|
||||
// The documents step auto-uploads whatever the user selected as they
|
||||
// continue (partial uploads are allowed — required-doc completeness is
|
||||
// re-checked on resume). A failed upload holds them on the step.
|
||||
// The documents step hard-blocks on required company documents and a
|
||||
// business license per operational profile before it auto-uploads and
|
||||
// submits — no partial-completion path forward.
|
||||
if (step === "documents") {
|
||||
const docErrors = validateRequiredDocuments();
|
||||
const licenseErrors = validateLicenses();
|
||||
if (Object.keys(docErrors).length > 0 || Object.keys(licenseErrors).length > 0) {
|
||||
setDocumentFieldErrors(docErrors);
|
||||
setLicenseFieldErrors(licenseErrors);
|
||||
setSaveError("Please upload all required documents before continuing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (onUploadDocuments) {
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -410,12 +497,6 @@ export default function CompanyProfileForm({
|
||||
}
|
||||
}
|
||||
|
||||
if (!licenseComplete) {
|
||||
setSaveError(
|
||||
"Please upload a business license for each of your operational profiles.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
setSaveError(null);
|
||||
handleSubmit((data) => onSubmit(buildPayload(data, user)))();
|
||||
return;
|
||||
@@ -450,8 +531,6 @@ export default function CompanyProfileForm({
|
||||
onDataLoaded={handleETradeDataLoaded}
|
||||
/>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<TextInput
|
||||
label="Company Name"
|
||||
placeholder="Global Logistics Ltd"
|
||||
@@ -507,7 +586,7 @@ export default function CompanyProfileForm({
|
||||
from eTrade · read-only
|
||||
</Text>
|
||||
</Group>
|
||||
<SimpleGrid cols={2} spacing="md">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<ReadOnlyField
|
||||
label="License Number"
|
||||
value={watch("licenceNumber")}
|
||||
@@ -586,22 +665,19 @@ export default function CompanyProfileForm({
|
||||
|
||||
{step === "personnel" && (
|
||||
<>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
{etradeOwner && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
leftSection={<UserCheck size={14} />}
|
||||
onClick={useOwnerAsManager}
|
||||
>
|
||||
Use owner as manager
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={600} size="sm" c="edr-text">
|
||||
General Manager
|
||||
</Text>
|
||||
<LinkCheckboxCard
|
||||
checked={gmSameAsOwner}
|
||||
onToggle={toggleGmSameAsOwner}
|
||||
title="Same as business owner"
|
||||
description={
|
||||
etradeOwner
|
||||
? "Reuse the eTrade-registered owner's name and phone (email from your account). Uncheck to enter different details."
|
||||
: "Reuse your account's name, email and phone. Uncheck to enter different details."
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Name"
|
||||
placeholder="Abebe Bikila"
|
||||
@@ -737,15 +813,17 @@ export default function CompanyProfileForm({
|
||||
file={uploadSetting}
|
||||
value={documentFiles}
|
||||
uploadedKeys={uploadedDocumentKeys}
|
||||
errors={documentFieldErrors}
|
||||
containerClassName="lg:grid grid-cols-2 items-stretch"
|
||||
onChange={setDocumentFiles}
|
||||
onChange={handleDocumentFilesChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<RoleLicenseStep
|
||||
profiles={roleProfiles ?? []}
|
||||
value={licenseFiles ?? {}}
|
||||
onChange={onLicenseChange ?? (() => { })}
|
||||
onChange={handleLicenseFilesChange}
|
||||
errors={licenseFieldErrors}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { type FormEvent, useState } from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
import AuthShell from "@/components/auth/AuthShell";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
@@ -24,7 +26,6 @@ export default function LoginPage() {
|
||||
const { login } = useAuth();
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -41,8 +42,8 @@ export default function LoginPage() {
|
||||
} else {
|
||||
setError(result.error.message);
|
||||
}
|
||||
} catch {
|
||||
setError("An unexpected error occurred");
|
||||
} catch (err) {
|
||||
setError(extractApiError(err).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -64,60 +65,45 @@ export default function LoginPage() {
|
||||
</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">
|
||||
Email or Phone <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label="Email or Phone"
|
||||
placeholder="name@company.com or 09XXXXXXXX"
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={loading}
|
||||
value={identifier}
|
||||
onChange={(event) => setIdentifier(event.target.value)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-gray-800">Password</span>
|
||||
<Link
|
||||
to="#"
|
||||
className="text-xs font-semibold text-primary hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<PasswordInput
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={loading}
|
||||
autoComplete="username"
|
||||
className={fieldClass}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-800">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<a href="#" className="text-xs font-semibold text-primary hover:underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
disabled={loading}
|
||||
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>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2.5 text-sm text-red-700">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<button type="submit" disabled={loading} className={primaryButtonClass}>
|
||||
{loading ? "Signing in..." : "Sign In"}
|
||||
</button>
|
||||
<Button type="submit" color="edr-green" fullWidth loading={loading}>
|
||||
Sign In
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Don't have an account?{" "}
|
||||
@@ -129,7 +115,7 @@ export default function LoginPage() {
|
||||
Create an account
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
</form>
|
||||
</AuthShell>
|
||||
);
|
||||
|
||||
@@ -34,14 +34,15 @@ import { ControlledPhoneField, isValidPhone } from "@/components/PhoneField";
|
||||
import { api } from "@/services/api";
|
||||
import { extractApiError } from "@/utils/result";
|
||||
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
{ label: "One uppercase letter", test: (v: string) => /[A-Z]/.test(v) },
|
||||
{ label: "One lowercase letter", test: (v: string) => /[a-z]/.test(v) },
|
||||
{ label: "One number", test: (v: string) => /\d/.test(v) },
|
||||
{ label: "One special character", test: (v: string) => /[^A-Za-z0-9]/.test(v) },
|
||||
{
|
||||
label: "One special character",
|
||||
test: (v: string) => /[^A-Za-z0-9]/.test(v),
|
||||
},
|
||||
] as const;
|
||||
|
||||
const userSchema = z
|
||||
@@ -52,8 +53,14 @@ const userSchema = z
|
||||
.min(1, "Phone number is required")
|
||||
.refine(isValidPhone, "Enter a valid phone number"),
|
||||
userType: z.string(),
|
||||
firstName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
lastName: z.object({ en: z.string().min(2, "Name is required"), am: z.string().nullable() }),
|
||||
firstName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
lastName: z.object({
|
||||
en: z.string().min(2, "Name is required"),
|
||||
am: z.string().nullable(),
|
||||
}),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
@@ -132,12 +139,30 @@ export default function SignupPage() {
|
||||
|
||||
const passwordValue = watch("password") ?? "";
|
||||
|
||||
// Step 1 — form is valid: send a fresh code to the chosen channel, then
|
||||
// move to the OTP challenge.
|
||||
// Step 1 — form is valid: make sure the email/phone aren't already
|
||||
// registered, then send a fresh code to the chosen channel and move to
|
||||
// the OTP challenge.
|
||||
const requestOtp = async (data: FormData) => {
|
||||
setError(null);
|
||||
setSending(true);
|
||||
try {
|
||||
const availability = await api.auth.checkAvailability.call({
|
||||
email: data.email,
|
||||
phone: data.phone,
|
||||
});
|
||||
if (availability.emailTaken && availability.phoneTaken) {
|
||||
setError("An account with this email and phone number already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.emailTaken) {
|
||||
setError("An account with this email already exists.");
|
||||
return;
|
||||
}
|
||||
if (availability.phoneTaken) {
|
||||
setError("An account with this phone number already exists.");
|
||||
return;
|
||||
}
|
||||
|
||||
await api.auth.sendOTP.call(
|
||||
channel === "email" ? { email: data.email } : { phone: data.phone },
|
||||
);
|
||||
@@ -217,242 +242,270 @@ export default function SignupPage() {
|
||||
|
||||
return (
|
||||
<AuthShell
|
||||
tagline="Smart Freight Operations"
|
||||
taglineBody="Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
tagline= "Smart Freight Operations"
|
||||
taglineBody = "Join EDR Freight to manage shipments, track consignments, and streamline logistics workflows across Ethiopia and Djibouti."
|
||||
>
|
||||
<div className="flex w-full flex-col">
|
||||
<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>
|
||||
|
||||
{stage === "form" ? (
|
||||
<form onSubmit={handleSubmit(requestOtp)} className="flex w-full flex-col">
|
||||
<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">
|
||||
Create account
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
Register to access EDR Freight services.
|
||||
<div className="flex w-full flex-col" >
|
||||
{ stage === "form" ? (
|
||||
<form
|
||||
onSubmit= { handleSubmit(requestOtp) }
|
||||
className = "flex w-full flex-col"
|
||||
>
|
||||
<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" >
|
||||
Create account
|
||||
</h1>
|
||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||
Register to access EDR Freight services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Stack gap="md">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<TextInput
|
||||
< Stack gap = "sm" >
|
||||
<SimpleGrid cols={ { base: 1, sm: 2 } } spacing = "md" >
|
||||
<TextInput
|
||||
label="First name"
|
||||
placeholder="John"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.firstName?.en?.message}
|
||||
{...register("firstName.en")}
|
||||
placeholder = "John"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.firstName?.en?.message }
|
||||
{...register("firstName.en") }
|
||||
/>
|
||||
<TextInput
|
||||
label="Last name"
|
||||
placeholder="Doe"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.lastName?.en?.message}
|
||||
{...register("lastName.en")}
|
||||
< TextInput
|
||||
label = "Last name"
|
||||
placeholder = "Doe"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.lastName?.en?.message }
|
||||
{...register("lastName.en") }
|
||||
/>
|
||||
</SimpleGrid>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="john@example.com"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.email?.message}
|
||||
{...register("email")}
|
||||
< TextInput
|
||||
label = "Email"
|
||||
type = "email"
|
||||
placeholder = "john@example.com"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.email?.message }
|
||||
{...register("email") }
|
||||
/>
|
||||
|
||||
<ControlledPhoneField
|
||||
control={control}
|
||||
name="phone"
|
||||
label="Phone"
|
||||
required
|
||||
disabled={sending}
|
||||
/>
|
||||
< ControlledPhoneField
|
||||
control = { control }
|
||||
name = "phone"
|
||||
label = "Phone"
|
||||
required
|
||||
disabled = { sending }
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Send verification code via
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
fullWidth
|
||||
disabled={sending}
|
||||
value={channel}
|
||||
onChange={(v) => setChannel(v as OtpChannel)}
|
||||
data={[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Smartphone size={14} /> Phone
|
||||
</span>
|
||||
<div className="space-y-1.5" >
|
||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||
Send verification code via
|
||||
</Text>
|
||||
< SegmentedControl
|
||||
fullWidth
|
||||
disabled = { sending }
|
||||
value = { channel }
|
||||
onChange = {(v) => setChannel(v as OtpChannel)
|
||||
}
|
||||
data = {
|
||||
[
|
||||
{
|
||||
value: "phone",
|
||||
label: (
|
||||
<span className= "flex items-center justify-center gap-1.5" >
|
||||
<Smartphone size={ 14} /> Phone
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className="flex items-center justify-center gap-1.5">
|
||||
<Mail size={14} /> Email
|
||||
</span>
|
||||
},
|
||||
{
|
||||
value: "email",
|
||||
label: (
|
||||
<span className= "flex items-center justify-center gap-1.5" >
|
||||
<Mail size={ 14 } /> Email
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<PasswordInput
|
||||
< div >
|
||||
<PasswordInput
|
||||
label="Password"
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.password?.message}
|
||||
{...register("password")}
|
||||
placeholder = "Create a strong password"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.password?.message }
|
||||
{...register("password") }
|
||||
/>
|
||||
{passwordValue.length > 0 ? (
|
||||
<div className="mt-2 space-y-1">
|
||||
{passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div key={req.label} className="flex items-center gap-2">
|
||||
<span
|
||||
className={`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${
|
||||
met ? "bg-primary text-primary-foreground" : "bg-gray-200 text-gray-500"
|
||||
}`}
|
||||
{
|
||||
passwordValue.length > 0 ? (
|
||||
<div className= "mt-2 space-y-1" >
|
||||
{
|
||||
passwordRequirements.map((req) => {
|
||||
const met = req.test(passwordValue);
|
||||
return (
|
||||
<div
|
||||
key= { req.label }
|
||||
className = "flex items-center gap-2"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
`flex h-4 w-4 shrink-0 items-center justify-center rounded-full ${met
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-gray-200 text-gray-500"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{met ? <Check className="h-2.5 w-2.5" /> : <X className="h-2.5 w-2.5" />}
|
||||
</span>
|
||||
<span className={`text-xs ${met ? "text-primary" : "text-gray-500"}`}>
|
||||
{req.label}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
met?(
|
||||
<Check className = "h-2.5 w-2.5" />
|
||||
): (
|
||||
<X className = "h-2.5 w-2.5" />
|
||||
)
|
||||
}
|
||||
</span>
|
||||
< span
|
||||
className = {`text-xs ${met ? "text-primary" : "text-gray-500"}`
|
||||
}
|
||||
>
|
||||
{ req.label }
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PasswordInput
|
||||
label="Confirm password"
|
||||
placeholder="Re-enter your password"
|
||||
required
|
||||
disabled={sending}
|
||||
error={errors.confirmPassword?.message}
|
||||
{...register("confirmPassword")}
|
||||
< PasswordInput
|
||||
label = "Confirm password"
|
||||
placeholder = "Re-enter your password"
|
||||
required
|
||||
disabled = { sending }
|
||||
error = { errors.confirmPassword?.message }
|
||||
{...register("confirmPassword") }
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{error}
|
||||
</Alert>
|
||||
{
|
||||
error ? (
|
||||
<Alert
|
||||
color= "red"
|
||||
variant = "light"
|
||||
icon = {< AlertCircle size = { 18} />}
|
||||
>
|
||||
{ error }
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
<Button
|
||||
type="submit"
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={sending}
|
||||
rightSection={!sending ? <ArrowRight size={16} /> : undefined}
|
||||
color = "edr-green"
|
||||
fullWidth
|
||||
loading = { sending }
|
||||
rightSection = {!sending ? <ArrowRight size={ 16 } /> : undefined}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-gray-500">
|
||||
Already have an account?{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate("/login")}
|
||||
className="font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
< p className = "text-center text-sm text-gray-500" >
|
||||
Already have an account ? { " "}
|
||||
< button
|
||||
type = "button"
|
||||
onClick = {() => navigate("/login")}
|
||||
className = "font-semibold text-primary hover:underline"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</p>
|
||||
</Stack>
|
||||
</form>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<div className="mb-1 flex justify-center">
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary">
|
||||
<ShieldCheck size={22} />
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-center">
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl">
|
||||
Verify your {otpChannel === "email" ? "email" : "phone"}
|
||||
</h1>
|
||||
<p className="text-sm leading-relaxed text-gray-500">
|
||||
We sent a 6-digit code to{" "}
|
||||
<span className="font-medium text-gray-700">
|
||||
{otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
. Enter it to finish creating your account.
|
||||
<Stack gap= "md" >
|
||||
<div className="mb-1 flex justify-center" >
|
||||
<span className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10 text-primary" >
|
||||
<ShieldCheck size={ 22 } />
|
||||
</span>
|
||||
</div>
|
||||
< div className = "space-y-1.5 text-center" >
|
||||
<h1 className="text-xl font-bold tracking-tight text-gray-900 sm:text-2xl" >
|
||||
Verify your { otpChannel === "email" ? "email" : "phone" }
|
||||
</h1>
|
||||
< p className = "text-sm leading-relaxed text-gray-500" >
|
||||
We sent a 6 - digit code to{ " " }
|
||||
<span className="font-medium text-gray-700" >
|
||||
{ otpChannel === "email"
|
||||
? maskEmail(pendingData?.email ?? "")
|
||||
: maskPhone(pendingData?.phone ?? "")}
|
||||
</span>
|
||||
.Enter it to finish creating your account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{otpError ? (
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
{otpError}
|
||||
</Alert>
|
||||
{
|
||||
otpError ? (
|
||||
<Alert
|
||||
color= "red"
|
||||
variant = "light"
|
||||
icon = {< AlertCircle size = { 18} />}
|
||||
>
|
||||
{ otpError }
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6} align="center">
|
||||
<Text size="sm" fw={500} c="edr-text">
|
||||
Verification code
|
||||
</Text>
|
||||
<PinInput
|
||||
length={6}
|
||||
type="number"
|
||||
oneTimeCode
|
||||
value={otpCode}
|
||||
placeholder="0"
|
||||
disabled={verifying}
|
||||
styles={{ input: { textAlign: "center" } }}
|
||||
onChange={setOtpCode}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack gap={ 6 } align = "center" >
|
||||
<Text size="sm" fw = { 500} c = "edr-text" >
|
||||
Verification code
|
||||
</Text>
|
||||
< PinInput
|
||||
length = { 6}
|
||||
type = "number"
|
||||
oneTimeCode
|
||||
value = { otpCode }
|
||||
placeholder = "0"
|
||||
disabled = { verifying }
|
||||
styles = {{ input: { textAlign: "center" } }}
|
||||
onChange = { setOtpCode }
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
color="edr-green"
|
||||
fullWidth
|
||||
loading={verifying}
|
||||
disabled={verifying || otpCode.trim().length !== 6}
|
||||
onClick={confirmOtp}
|
||||
>
|
||||
Verify & create account
|
||||
</Button>
|
||||
< Button
|
||||
color = "edr-green"
|
||||
fullWidth
|
||||
loading = { verifying }
|
||||
disabled = { verifying || otpCode.trim().length !== 6}
|
||||
onClick = { confirmOtp }
|
||||
>
|
||||
Verify & amp; create account
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
< div className = "flex items-center justify-between" >
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<ArrowLeft size={14} />}
|
||||
disabled={sending || verifying}
|
||||
onClick={() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
color = "gray"
|
||||
leftSection = {< ArrowLeft size = { 14} />}
|
||||
disabled = { sending || verifying}
|
||||
onClick = {() => {
|
||||
setStage("form");
|
||||
setOtpError(null);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="edr-green"
|
||||
leftSection={<RotateCw size={14} />}
|
||||
disabled={resendIn > 0 || sending || verifying}
|
||||
onClick={resendOtp}
|
||||
>
|
||||
{resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
Back
|
||||
</Button>
|
||||
< Button
|
||||
variant = "subtle"
|
||||
color = "edr-green"
|
||||
leftSection = {< RotateCw size = { 14} />}
|
||||
disabled = { resendIn > 0 || sending || verifying}
|
||||
onClick = { resendOtp }
|
||||
>
|
||||
{ resendIn > 0 ? `Resend in ${resendIn}s` : "Resend code"}
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
)}
|
||||
</div>
|
||||
</AuthShell>
|
||||
</div>
|
||||
</AuthShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
MultiSelect,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Download, Lock, Truck } from "lucide-react";
|
||||
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const errorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
|
||||
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||
if (data?.message) return data.message;
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
};
|
||||
|
||||
export function CustomerTruckAssignmentCard({
|
||||
booking,
|
||||
onAssigned,
|
||||
@@ -29,43 +51,86 @@ export function CustomerTruckAssignmentCard({
|
||||
booking: Freight.IBooking;
|
||||
onAssigned: () => void;
|
||||
}) {
|
||||
const assigned = Boolean(booking.customerTruckAssignedAt);
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
|
||||
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
|
||||
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
|
||||
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
|
||||
booking.customerTruckContainerNumber ?? "",
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const trucksKey = ["customer-trucks", booking.id];
|
||||
|
||||
const { data: trucks = [], isLoading } = useQuery({
|
||||
queryKey: trucksKey,
|
||||
queryFn: () => customerTrucksService.list(booking.id),
|
||||
});
|
||||
|
||||
const [plateNumber, setPlateNumber] = useState("");
|
||||
const [driverName, setDriverName] = useState("");
|
||||
const [truckType, setTruckType] = useState("");
|
||||
const [containers, setContainers] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||
);
|
||||
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||
(n) => !assignedNumbers.has(n),
|
||||
);
|
||||
|
||||
const submit = async () => {
|
||||
const payload = {
|
||||
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
|
||||
};
|
||||
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
|
||||
setError("All truck assignment fields are required.");
|
||||
return;
|
||||
}
|
||||
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
|
||||
setError("Container number must match ISO format, e.g. ABCD1234567.");
|
||||
return;
|
||||
}
|
||||
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
||||
// staff register + weigh what was loaded when the truck leaves.
|
||||
const isExport = booking.tradeDirection === "EXPORT";
|
||||
|
||||
const resetForm = () => {
|
||||
setPlateNumber("");
|
||||
setDriverName("");
|
||||
setTruckType("");
|
||||
setContainers([]);
|
||||
setError(null);
|
||||
await assignMutation.mutateAsync({ id: booking.id, payload });
|
||||
onAssigned();
|
||||
};
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
customerTrucksService.add(booking.id, {
|
||||
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
// Import: containers are registered + weighed on departure, not here.
|
||||
containerNumbers: isExport ? containers : [],
|
||||
}),
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
resetForm();
|
||||
onAssigned();
|
||||
toast.success("Truck added");
|
||||
},
|
||||
onError: (e) => setError(errorMessage(e, "Could not add truck")),
|
||||
});
|
||||
|
||||
const removeMutation = useMutation({
|
||||
mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId),
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
onAssigned();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "Could not remove truck")),
|
||||
});
|
||||
|
||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||
const downloadFreightOrder = async () => {
|
||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||
};
|
||||
|
||||
const submitAdd = () => {
|
||||
if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) {
|
||||
setError("Plate number, driver name and truck type are required.");
|
||||
return;
|
||||
}
|
||||
if (isExport && (containers.length < 1 || containers.length > 2)) {
|
||||
setError("Select 1 or 2 container numbers for this truck.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
addMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard>
|
||||
<Stack gap="md">
|
||||
@@ -74,64 +139,135 @@ export function CustomerTruckAssignmentCard({
|
||||
<Truck size={18} color="#0a9f6a" />
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
{assigned && (
|
||||
<Group gap={6} c="#0a9f6a">
|
||||
<Lock size={14} />
|
||||
<Text size="sm" fw={700}>
|
||||
Truck Assigned
|
||||
</Text>
|
||||
</Group>
|
||||
{trucks.length > 0 && (
|
||||
<Text size="sm" fw={700} c="#0a9f6a">
|
||||
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Assigned trucks */}
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="sm">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
trucks.map((t) => (
|
||||
<Group
|
||||
key={t.id}
|
||||
justify="space-between"
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
|
||||
>
|
||||
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fz="14px" fw={700} c="#10202F">
|
||||
{t.plateNumber}
|
||||
</Text>
|
||||
{t.arrivedAt ? (
|
||||
<Badge color="green" variant="light" leftSection={<CheckCircle2 size={12} />}>
|
||||
Arrived
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge color="orange" variant="light" leftSection={<Clock size={12} />}>
|
||||
Awaiting arrival
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz="12.5px" c="#6B7C8E">
|
||||
{t.driverName} · {t.truckType}
|
||||
</Text>
|
||||
<Group gap={6}>
|
||||
{(t.containers ?? []).map((c) => (
|
||||
<Badge key={c.id} variant="outline" color="gray">
|
||||
{c.containerNumber}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
{!t.arrivedAt && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove truck"
|
||||
onClick={() => removeMutation.mutate(t.id)}
|
||||
loading={removeMutation.isPending}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
))
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert color="red" variant="light">
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
{assignMutation.isError && (
|
||||
<Alert color="red" variant="light">
|
||||
{assignMutation.error instanceof Error
|
||||
? assignMutation.error.message
|
||||
: "Truck assignment failed."}
|
||||
</Alert>
|
||||
|
||||
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
||||
{(isExport ? availableContainers.length > 0 : true) ? (
|
||||
<>
|
||||
<Divider label="Add a truck" labelPosition="center" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<TextInput
|
||||
label="Truck Plate Number"
|
||||
required
|
||||
value={plateNumber}
|
||||
onChange={(e) => setPlateNumber(e.currentTarget.value.toUpperCase())}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver Name"
|
||||
required
|
||||
value={driverName}
|
||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Truck Type"
|
||||
required
|
||||
data={TRUCK_TYPES}
|
||||
value={truckType || null}
|
||||
onChange={(value) => setTruckType(value ?? "")}
|
||||
/>
|
||||
{isExport && (
|
||||
<MultiSelect
|
||||
label="Containers to load (1–2)"
|
||||
required
|
||||
placeholder="Select container numbers"
|
||||
data={availableContainers}
|
||||
value={containers}
|
||||
onChange={setContainers}
|
||||
maxValues={2}
|
||||
searchable
|
||||
nothingFoundMessage="No unassigned containers"
|
||||
/>
|
||||
)}
|
||||
</SimpleGrid>
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={submitAdd}
|
||||
loading={addMutation.isPending}
|
||||
>
|
||||
Add truck
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
trucks.length > 0 && (
|
||||
<Text fz="12.5px" c="#9AA8B5">
|
||||
All containers on this booking have been assigned to a truck.
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
<TextInput
|
||||
label="Truck Plate Number"
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver Name"
|
||||
required
|
||||
value={driverName}
|
||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
<Select
|
||||
label="Truck Type"
|
||||
required
|
||||
data={TRUCK_TYPES}
|
||||
value={truckType || null}
|
||||
onChange={(value) => setTruckType(value ?? "")}
|
||||
disabled={assigned}
|
||||
/>
|
||||
<TextInput
|
||||
label="Container Number to Load"
|
||||
required
|
||||
value={containerNumberToLoad}
|
||||
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
||||
readOnly={assigned}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Group justify="flex-end">
|
||||
{assigned ? (
|
||||
{trucks.length > 0 && (
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<Download size={16} />}
|
||||
color="edr-green"
|
||||
onClick={downloadFreightOrder}
|
||||
@@ -139,12 +275,8 @@ export function CustomerTruckAssignmentCard({
|
||||
>
|
||||
Generate Freight Order Copies
|
||||
</Button>
|
||||
) : (
|
||||
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
|
||||
Verify & Submit Assignment
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import { ActionIcon, Box, Group, Stack, Text } from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Download, Receipt } from "lucide-react";
|
||||
import { ActionIcon, Box, Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { CreditCard, Download, Receipt } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { paymentsService, type PaymentMethod } from "@/services/payments.service";
|
||||
import {
|
||||
warehouseInvoicesService,
|
||||
type PortalWarehouseInvoice,
|
||||
} from "@/services/warehouse-invoices.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
|
||||
/** Warehouse fee invoices the customer can still settle online. */
|
||||
const PAYABLE_STATUSES = new Set(["ISSUED", "PARTIALLY_PAID"]);
|
||||
const isPayable = (inv: PortalWarehouseInvoice) =>
|
||||
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||
|
||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
||||
|
||||
@@ -44,10 +52,12 @@ function StatusPill({ status }: { status: string }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Warehouse fee invoices linked to this booking — display + PDF download only.
|
||||
* Paying them online is tracked separately (in-system demurrage/storage
|
||||
* payment). Renders nothing when the booking has no warehouse fees. Carries
|
||||
* `id="warehouse-payments"` so the invoice detail page can deep-link here.
|
||||
* Warehouse fee invoices linked to this booking. Customers can pay outstanding
|
||||
* demurrage/storage invoices online (Telebirr/Waafi) so they can then sign the
|
||||
* delivery handover; paid invoices expose the receipt PDF. The backoffice cash
|
||||
* `/pay` (record-a-payment) path is unaffected. Renders nothing when the booking
|
||||
* has no warehouse fees. Carries `id="warehouse-payments"` so the invoice detail
|
||||
* page can deep-link here.
|
||||
*/
|
||||
export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
const { data: invoices = [] } = useQuery({
|
||||
@@ -55,6 +65,41 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
queryFn: () => warehouseInvoicesService.listForBooking(bookingId),
|
||||
});
|
||||
|
||||
const [payInvoice, setPayInvoice] = useState<PortalWarehouseInvoice | null>(null);
|
||||
|
||||
const payMutation = useMutation({
|
||||
mutationFn: (method: PaymentMethod) => {
|
||||
if (!payInvoice) throw new Error("No invoice selected for payment.");
|
||||
return warehouseInvoicesService.payOnline(payInvoice.id, {
|
||||
method,
|
||||
platform: "web",
|
||||
});
|
||||
},
|
||||
onSuccess: (data, method) => {
|
||||
if (!payInvoice) return;
|
||||
// Redirect to the provider (or the fallback checkout page) — same as the
|
||||
// booking "Pay now" flow, so behaviour is identical everywhere.
|
||||
const redirectUrl =
|
||||
data?.clientAction?.type === "REDIRECT" && data.clientAction.url
|
||||
? data.clientAction.url
|
||||
: paymentsService.checkoutUrlForInvoice({ invoiceId: payInvoice.id, method });
|
||||
window.location.href = redirectUrl;
|
||||
},
|
||||
});
|
||||
|
||||
const payError = payMutation.isError
|
||||
? payMutation.error instanceof Error
|
||||
? payMutation.error.message
|
||||
: "Could not start payment. Please try again."
|
||||
: null;
|
||||
|
||||
const closePayModal = () => {
|
||||
if (!payMutation.isPending) {
|
||||
setPayInvoice(null);
|
||||
payMutation.reset();
|
||||
}
|
||||
};
|
||||
|
||||
if (invoices.length === 0) return null;
|
||||
|
||||
const download = async (inv: PortalWarehouseInvoice) => {
|
||||
@@ -128,6 +173,17 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
</Text>
|
||||
</Box>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{isPayable(inv) && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius={10}
|
||||
color="edr-green"
|
||||
leftSection={<CreditCard size={14} />}
|
||||
onClick={() => setPayInvoice(inv)}
|
||||
>
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
@@ -151,6 +207,18 @@ export function WarehousePaymentsSection({ bookingId }: { bookingId: string }) {
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<PaymentMethodModal
|
||||
opened={payInvoice !== null}
|
||||
onClose={closePayModal}
|
||||
amountLabel={
|
||||
payInvoice ? money(payInvoice.balanceAmount, payInvoice.currency) : undefined
|
||||
}
|
||||
currency={payInvoice?.currency}
|
||||
onConfirm={(method) => payMutation.mutate(method)}
|
||||
processing={payMutation.isPending}
|
||||
error={payError}
|
||||
/>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,953 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
FileEdit,
|
||||
LayoutList,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
Search,
|
||||
Train,
|
||||
Wallet,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
|
||||
import { PayNowButton } from "./payments/PayNowButton";
|
||||
import { BookingActionButton } from "./clearance/BookingActionButton";
|
||||
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
|
||||
import {
|
||||
BookingTypeBadge,
|
||||
CargoModeCell,
|
||||
PaymentBadge,
|
||||
SchedulingCell,
|
||||
} from "./booking-display";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import type { BookingListFilter } from "@/services/bookings.service";
|
||||
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
|
||||
import type { Freight } from "@edr/types";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
|
||||
// Bookings that have left (or are leaving) the yard can be tracked live.
|
||||
const TRACKABLE_STATUSES = new Set([
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"COMPLETED",
|
||||
"DELIVERED",
|
||||
]);
|
||||
|
||||
// ── Status filter options (grouped by lifecycle) ──────────────────────────────
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
statuses: undefined as string | undefined,
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
statuses:
|
||||
"SUBMITTED,CHANGES_REQUESTED,PENDING_APPROVAL,APPROVED_PENDING_SIGNATURE,APPROVED,CONTRACT_READY,SIGNED_CUSTOMER,FULLY_EXECUTED,PENDING_CONSOLIDATION,CONSOLIDATED",
|
||||
},
|
||||
{ key: "draft", label: "Drafts", statuses: "DRAFT" },
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
statuses:
|
||||
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
|
||||
},
|
||||
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
|
||||
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
|
||||
{
|
||||
key: "closed",
|
||||
label: "Cancelled / rejected",
|
||||
statuses: "CANCELLED,REJECTED",
|
||||
},
|
||||
] as const;
|
||||
|
||||
type StatusFilterKey = (typeof STATUS_FILTERS)[number]["key"];
|
||||
|
||||
const SELECT_DATA = STATUS_FILTERS.map((f) => ({
|
||||
value: f.key,
|
||||
label: f.label,
|
||||
}));
|
||||
|
||||
// Sort options — server-side ordering on the booking list.
|
||||
const SORT_OPTIONS = [
|
||||
{ value: "createdAt:DESC", label: "Newest first" },
|
||||
{ value: "createdAt:ASC", label: "Oldest first" },
|
||||
{ value: "scheduledDate:ASC", label: "Ship date ↑" },
|
||||
{ value: "scheduledDate:DESC", label: "Ship date ↓" },
|
||||
{ value: "reference:ASC", label: "Reference A–Z" },
|
||||
{ value: "reference:DESC", label: "Reference Z–A" },
|
||||
] as const;
|
||||
|
||||
// ── Summary stat cards (clickable lifecycle filters) ──────────────────────────
|
||||
|
||||
const STAT_CARDS: Array<{
|
||||
key: StatusFilterKey;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
}> = [
|
||||
{
|
||||
key: "all",
|
||||
label: "All bookings",
|
||||
icon: LayoutList,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
{
|
||||
key: "active",
|
||||
label: "In progress",
|
||||
icon: Package,
|
||||
iconBg: "#FDF3E0",
|
||||
iconColor: "#C77F09",
|
||||
},
|
||||
{
|
||||
key: "payment",
|
||||
label: "Awaiting payment",
|
||||
icon: Wallet,
|
||||
iconBg: "#FEF6E6",
|
||||
iconColor: "#F2A516",
|
||||
},
|
||||
{
|
||||
key: "draft",
|
||||
label: "Drafts",
|
||||
icon: FileEdit,
|
||||
iconBg: "#F1F4F7",
|
||||
iconColor: "#475569",
|
||||
},
|
||||
{
|
||||
key: "done",
|
||||
label: "Completed",
|
||||
icon: CheckCircle2,
|
||||
iconBg: "#ECF6F1",
|
||||
iconColor: "#0A8A5F",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Status badge (reuses the shared portal status config) ─────────────────────
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
const label = cfg?.badgeLabel ?? status.replace(/_/g, " ");
|
||||
const bg = cfg ? `var(--mantine-color-${cfg.badgeBg}-0, #F1F4F7)` : "#F1F4F7";
|
||||
const text = cfg
|
||||
? `var(--mantine-color-${cfg.badgeText}-7, #475569)`
|
||||
: "#475569";
|
||||
const dot = cfg
|
||||
? `var(--mantine-color-${cfg.badgeDot}-6, #94A3B8)`
|
||||
: "#94A3B8";
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
borderRadius: 999,
|
||||
backgroundColor: bg,
|
||||
padding: "5px 11px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: dot,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} style={{ color: text, whiteSpace: "nowrap" }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Context-sensitive action button ───────────────────────────────────────────
|
||||
|
||||
function PrimaryAction({
|
||||
booking,
|
||||
onNavigate,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
onNavigate: (path: string) => void;
|
||||
}) {
|
||||
const { status, id } = booking;
|
||||
const go = () => onNavigate(`/bookings/${id}`);
|
||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||
if (status === "DRAFT") {
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
fw={700}
|
||||
fz={13}
|
||||
rightSection={<ArrowRight size={14} />}
|
||||
style={{
|
||||
backgroundColor: "var(--mantine-color-edr-ink-0)",
|
||||
color: "#fff",
|
||||
}}
|
||||
onClick={go}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
// CHANGES_REQUESTED + clearance/operation steps are handled in place by a
|
||||
// modal (update & resubmit, upload clearance docs, schedule & proceed).
|
||||
if (bookingHasInlineAction(booking)) {
|
||||
return <BookingActionButton booking={booking} size="xs" />;
|
||||
}
|
||||
const payableStatus = isGeneralContract
|
||||
? "FULLY_EXECUTED"
|
||||
: "SELECTED_FOR_BATCH";
|
||||
if (status === payableStatus && booking.paymentStatus !== "PAID") {
|
||||
return <PayNowButton booking={booking} />;
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="default"
|
||||
fw={600}
|
||||
fz={13}
|
||||
onClick={go}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<Text
|
||||
fz={11}
|
||||
fw={700}
|
||||
c="edr-muted"
|
||||
style={{
|
||||
letterSpacing: "0.6px",
|
||||
textTransform: "uppercase",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const hMeta = { headerClassName: "bg-[#F4F7FA]" };
|
||||
|
||||
function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime())
|
||||
? ""
|
||||
: d.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
// Lightweight count query for a single lifecycle filter (reads only `total`).
|
||||
function useStatusCount(statuses: string | undefined): number | undefined {
|
||||
const { data } = useQuery(
|
||||
api.bookings.list.queryOptions({
|
||||
input: { statuses, page: 1, pageSize: 1 },
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
);
|
||||
return data?.meta?.total;
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
card,
|
||||
active,
|
||||
count,
|
||||
onSelect,
|
||||
}: {
|
||||
card: (typeof STAT_CARDS)[number];
|
||||
active: boolean;
|
||||
count: number | undefined;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const Icon = card.icon;
|
||||
return (
|
||||
<Paper
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
p="md"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
transition: "box-shadow 140ms ease, border-color 140ms ease",
|
||||
borderColor: active ? "#F2A516" : "var(--mantine-color-edr-border-0)",
|
||||
boxShadow: active ? "0 0 0 1px #F2A516" : "none",
|
||||
}}
|
||||
>
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 11,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: card.iconBg,
|
||||
color: card.iconColor,
|
||||
}}
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={24} fw={800} lh={1.05} c="edr-text">
|
||||
{count ?? "—"}
|
||||
</Text>
|
||||
<Text fz={12} fw={600} c="edr-muted" truncate>
|
||||
{card.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BookingsListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilterKey>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
const [freightFilter, setFreightFilter] = useState<string | null>(null);
|
||||
const [sort, setSort] = useState<string>("createdAt:DESC");
|
||||
const [createdFrom, setCreatedFrom] = useState<string>("");
|
||||
const [createdTo, setCreatedTo] = useState<string>("");
|
||||
const [trackingBooking, setTrackingBooking] =
|
||||
useState<Freight.IBooking | null>(null);
|
||||
|
||||
const statuses = STATUS_FILTERS.find((t) => t.key === statusFilter)?.statuses;
|
||||
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
|
||||
|
||||
const resetPage = () =>
|
||||
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
|
||||
|
||||
const selectFilter = (key: StatusFilterKey) => {
|
||||
setStatusFilter(key);
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const hasExtraFilters =
|
||||
!!typeFilter || !!freightFilter || !!createdFrom || !!createdTo;
|
||||
const clearExtraFilters = () => {
|
||||
setTypeFilter(null);
|
||||
setFreightFilter(null);
|
||||
setCreatedFrom("");
|
||||
setCreatedTo("");
|
||||
resetPage();
|
||||
};
|
||||
|
||||
const filter: BookingListFilter = useMemo(
|
||||
() => ({
|
||||
statuses,
|
||||
bookingType: typeFilter ?? undefined,
|
||||
freightType: freightFilter ?? undefined,
|
||||
createdFrom: createdFrom || undefined,
|
||||
// include the whole selected end day
|
||||
createdTo: createdTo ? `${createdTo}T23:59:59.999Z` : undefined,
|
||||
page: pagination.pageIndex + 1,
|
||||
pageSize: pagination.pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
}),
|
||||
[
|
||||
statuses,
|
||||
typeFilter,
|
||||
freightFilter,
|
||||
createdFrom,
|
||||
createdTo,
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isLoading, isError } = useQuery(
|
||||
api.bookings.list.queryOptions({ input: filter }),
|
||||
);
|
||||
|
||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||
const allCount = useStatusCount(undefined);
|
||||
const activeCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "active")!.statuses,
|
||||
);
|
||||
const paymentCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "payment")!.statuses,
|
||||
);
|
||||
const draftCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "draft")!.statuses,
|
||||
);
|
||||
const doneCount = useStatusCount(
|
||||
STATUS_FILTERS.find((f) => f.key === "done")!.statuses,
|
||||
);
|
||||
const cardCounts: Record<StatusFilterKey, number | undefined> = {
|
||||
all: allCount,
|
||||
active: activeCount,
|
||||
payment: paymentCount,
|
||||
draft: draftCount,
|
||||
done: doneCount,
|
||||
transit: undefined,
|
||||
closed: undefined,
|
||||
};
|
||||
|
||||
const allItems = data?.items ?? [];
|
||||
const total = data?.meta?.total ?? allItems.length;
|
||||
|
||||
// Server handles status + pagination; reference search is applied on the page
|
||||
// (matches booking reference, contract reference, and route yards).
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return allItems;
|
||||
return allItems.filter((b) =>
|
||||
[
|
||||
b.reference,
|
||||
b.contractReference,
|
||||
b.originYard?.label,
|
||||
b.destinationYard?.label,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allItems, query]);
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||
const dataTableStatus = isLoading ? "loading" : isError ? "error" : "success";
|
||||
const showEmpty = !isLoading && !isError && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<Freight.IBooking>[] = [
|
||||
{
|
||||
id: "booking",
|
||||
size: 244,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const cargoLabel =
|
||||
b.freightType === "BULK" ? "Bulk cargo" : "Container";
|
||||
return (
|
||||
<Group gap={12} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 9,
|
||||
flexShrink: 0,
|
||||
backgroundColor: "var(--mantine-color-edr-soft-0)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Package
|
||||
size={18}
|
||||
color="var(--mantine-color-edr-green-7)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Text fz={14} fw={700} c="edr-text" truncate>
|
||||
{b.reference}
|
||||
</Text>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{cargoLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "contract",
|
||||
size: 150,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Contract" />,
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.contractReference;
|
||||
const cid = row.original.contractId;
|
||||
if (!ref) {
|
||||
return (
|
||||
<Text fz={13} c="edr-muted">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text
|
||||
fz={13}
|
||||
fw={600}
|
||||
c={cid ? "edr-green" : "edr-text"}
|
||||
style={{ whiteSpace: "nowrap", cursor: cid ? "pointer" : "default" }}
|
||||
onClick={
|
||||
cid
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/contracts/${cid}`);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{ref}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
size: 150,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Type" />,
|
||||
cell: ({ row }) => <BookingTypeBadge booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "cargo",
|
||||
size: 168,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Cargo" />,
|
||||
cell: ({ row }) => <CargoModeCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
size: 196,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Route" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code ?? "—";
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code ?? "—";
|
||||
const sub = fmtDate(b.scheduledDate ?? b.createdAt);
|
||||
return (
|
||||
<Box>
|
||||
<Text fz={13} fw={600} c="edr-text">
|
||||
{origin} → {dest}
|
||||
</Text>
|
||||
{sub && (
|
||||
<Text fz={12} c="edr-muted">
|
||||
{sub}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "payment",
|
||||
size: 130,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Payment" />,
|
||||
cell: ({ row }) => <PaymentBadge status={row.original.paymentStatus} />,
|
||||
},
|
||||
{
|
||||
id: "scheduling",
|
||||
size: 140,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Train" />,
|
||||
cell: ({ row }) => <SchedulingCell booking={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
size: 190,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Status" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "amount",
|
||||
size: 140,
|
||||
meta: hMeta,
|
||||
header: () => <ColHeader label="Amount" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original as Freight.IBooking & {
|
||||
totalAmount?: number;
|
||||
amount?: number;
|
||||
};
|
||||
const amount = b.totalAmount ?? b.amount ?? null;
|
||||
if (!amount) {
|
||||
return (
|
||||
<Text fz={14} fw={700} style={{ color: "#94A3B8" }}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text fz={14} fw={700} c="edr-text">
|
||||
ETB {amount.toLocaleString()}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
meta: hMeta,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const trackable = TRACKABLE_STATUSES.has(booking.status);
|
||||
return (
|
||||
<Group
|
||||
justify="flex-end"
|
||||
gap={8}
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trackable && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={<Train size={14} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track
|
||||
</Button>
|
||||
)}
|
||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item onClick={() => navigate(`/bookings/${booking.id}`)}>
|
||||
View details
|
||||
</Menu.Item>
|
||||
{trackable && (
|
||||
<Menu.Item
|
||||
leftSection={<Train size={15} />}
|
||||
onClick={() => setTrackingBooking(booking)}
|
||||
>
|
||||
Track shipment
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
{/* ── Page header ─────────────────────────────────────────────── */}
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Group gap={10} align="center">
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
</Group>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Track every cargo booking — from draft to delivery.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
component={Link}
|
||||
to="/contracts"
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
>
|
||||
New booking
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* ── Summary stat cards ──────────────────────────────────────── */}
|
||||
<SimpleGrid cols={{ base: 2, sm: 3, lg: 5 }} spacing="md">
|
||||
{STAT_CARDS.map((card) => (
|
||||
<StatCard
|
||||
key={card.key}
|
||||
card={card}
|
||||
active={statusFilter === card.key}
|
||||
count={cardCounts[card.key]}
|
||||
onSelect={() => selectFilter(card.key)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{/* ── Bookings table card ──────────────────────────────────────── */}
|
||||
<Card p={0} style={{ overflow: "hidden" }}>
|
||||
<Group
|
||||
justify="space-between"
|
||||
gap={12}
|
||||
px={20}
|
||||
py={14}
|
||||
wrap="wrap"
|
||||
style={{
|
||||
borderBottom: "1px solid var(--mantine-color-edr-border-0)",
|
||||
}}
|
||||
>
|
||||
<Group gap={10} wrap="wrap" style={{ flex: 1, minWidth: 260 }}>
|
||||
<TextInput
|
||||
placeholder="Search booking, contract, or route…"
|
||||
leftSection={<Search size={16} />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.currentTarget.value)}
|
||||
rightSection={
|
||||
query ? (
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
onClick={() => setQuery("")}
|
||||
>
|
||||
<X size={14} />
|
||||
</ActionIcon>
|
||||
) : null
|
||||
}
|
||||
radius="md"
|
||||
style={{ flex: 1, minWidth: 200, maxWidth: 340 }}
|
||||
/>
|
||||
<Select
|
||||
data={SELECT_DATA}
|
||||
value={statusFilter}
|
||||
onChange={(value) =>
|
||||
selectFilter((value as StatusFilterKey) ?? "all")
|
||||
}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
checkIconPosition="right"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 190 }}
|
||||
aria-label="Filter by status"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any type"
|
||||
data={[
|
||||
{ value: "ONE_TIME", label: "One-time" },
|
||||
{ value: "GENERAL_CONTRACT", label: "General contract" },
|
||||
]}
|
||||
value={typeFilter}
|
||||
onChange={(v) => {
|
||||
setTypeFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 170 }}
|
||||
aria-label="Filter by booking type"
|
||||
/>
|
||||
<Select
|
||||
placeholder="Any cargo"
|
||||
data={[
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
]}
|
||||
value={freightFilter}
|
||||
onChange={(v) => {
|
||||
setFreightFilter(v);
|
||||
resetPage();
|
||||
}}
|
||||
clearable
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 150 }}
|
||||
aria-label="Filter by cargo type"
|
||||
/>
|
||||
<Select
|
||||
data={SORT_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
}))}
|
||||
value={sort}
|
||||
onChange={(v) => {
|
||||
setSort(v ?? "createdAt:DESC");
|
||||
resetPage();
|
||||
}}
|
||||
allowDeselect={false}
|
||||
radius="md"
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
style={{ width: 160 }}
|
||||
aria-label="Sort bookings"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdFrom}
|
||||
onChange={(e) => {
|
||||
setCreatedFrom(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created from"
|
||||
placeholder="From"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
value={createdTo}
|
||||
onChange={(e) => {
|
||||
setCreatedTo(e.currentTarget.value);
|
||||
resetPage();
|
||||
}}
|
||||
radius="md"
|
||||
style={{ width: 150 }}
|
||||
aria-label="Created to"
|
||||
placeholder="To"
|
||||
/>
|
||||
{hasExtraFilters && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="sm"
|
||||
leftSection={<X size={14} />}
|
||||
onClick={clearExtraFilters}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Text fz={12} c="edr-muted">
|
||||
{total} booking{total !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap={4} px="lg" py={64} ta="center">
|
||||
<ThemeIcon
|
||||
size={56}
|
||||
radius="lg"
|
||||
color="edr-green"
|
||||
variant="light"
|
||||
mb="xs"
|
||||
>
|
||||
<Package size={28} />
|
||||
</ThemeIcon>
|
||||
<Text size="sm" fw={600} c="edr-text">
|
||||
{query
|
||||
? "No bookings match your search"
|
||||
: "No bookings here yet"}
|
||||
</Text>
|
||||
<Text size="xs" c="edr-muted" maw={320}>
|
||||
{query
|
||||
? "Try a different reference or clear the search."
|
||||
: "Bookings are created against a contract. Open a contract to book a shipment."}
|
||||
</Text>
|
||||
{!query && (
|
||||
<Button
|
||||
component={Link}
|
||||
to="/contracts"
|
||||
size="sm"
|
||||
mt="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
>
|
||||
Go to contracts
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={dataTableStatus}
|
||||
onRowClick={(row) =>
|
||||
navigate(`/bookings/${(row as Freight.IBooking).id}`)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount,
|
||||
totalCount: total,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
manualPagination: true,
|
||||
pageCount,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShipmentTrackingModal
|
||||
opened={trackingBooking !== null}
|
||||
onClose={() => setTrackingBooking(null)}
|
||||
bookingId={trackingBooking?.id ?? ""}
|
||||
bookingReference={trackingBooking?.reference ?? ""}
|
||||
originLabel={
|
||||
trackingBooking?.originYard?.label ??
|
||||
trackingBooking?.originYard?.code
|
||||
}
|
||||
destinationLabel={
|
||||
trackingBooking?.destinationYard?.label ??
|
||||
trackingBooking?.destinationYard?.code
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,70 @@ type BookingForm = UseFormReturn<
|
||||
BookingFormValues
|
||||
>;
|
||||
|
||||
/**
|
||||
* One numbered toggle per container unit in the line — tap units to mark how
|
||||
* many are hazardous/refrigerated (2 hazardous → toggle 2 units on). Selection
|
||||
* fills from unit 1: tapping unit N selects 1..N, tapping a selected unit N
|
||||
* keeps 1..N-1 — the count is always derived, never free-typed, so it can't
|
||||
* exceed the line quantity.
|
||||
*/
|
||||
function UnitCountToggles({
|
||||
total,
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
activeBg,
|
||||
activeBorder,
|
||||
activeColor,
|
||||
}: {
|
||||
total: number;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
label: string;
|
||||
activeBg: string;
|
||||
activeBorder: string;
|
||||
activeColor: string;
|
||||
}) {
|
||||
const count = Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text fz={12} fw={600} c="#4A5A68" mb={6}>
|
||||
{label} · {count}/{total} selected
|
||||
</Text>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Array.from({ length: total }, (_, i) => {
|
||||
const selected = i < count;
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={`Container ${i + 1}`}
|
||||
onClick={() => onChange(String(selected ? i : i + 1))}
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
minWidth: 40,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: "pointer",
|
||||
border: `1.5px solid ${selected ? activeBorder : "#E6ECF2"}`,
|
||||
background: selected ? activeBg : "#fff",
|
||||
color: selected ? activeColor : "#6B7C8E",
|
||||
transition:
|
||||
"background 120ms ease, border-color 120ms ease, color 120ms ease",
|
||||
}}
|
||||
>
|
||||
#{i + 1}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Step5CargoDetails({
|
||||
form,
|
||||
referenceData,
|
||||
@@ -144,16 +208,6 @@ export function Step5CargoDetails({
|
||||
const lineQtyOf = (index: number) =>
|
||||
Math.max(1, Number(form.getValues(`containers.${index}.qty`) ?? 1) || 1);
|
||||
const lineMax = (index: number) => lineQtyOf(index);
|
||||
// When a flag is switched on, default its count to the whole line.
|
||||
const defaultLineQty = (index: number) => lineQtyOf(index).toString();
|
||||
// Clamp a typed value into 1..lineQty (empty stays empty so the field can be
|
||||
// cleared; the schema flags an empty value as required while the switch is on).
|
||||
const clampToLine = (raw: string, index: number) => {
|
||||
if (raw === "") return "";
|
||||
const n = Number(raw);
|
||||
if (Number.isNaN(n)) return raw;
|
||||
return Math.min(lineQtyOf(index), Math.max(1, Math.floor(n))).toString();
|
||||
};
|
||||
// After the line quantity changes, pull any active count back within bounds.
|
||||
const clampDependentQty = (index: number, newLineQty: number) => {
|
||||
const max = Math.max(1, newLineQty);
|
||||
@@ -636,7 +690,7 @@ export function Step5CargoDetails({
|
||||
hazField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.hazardousQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
v ? "1" : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
@@ -645,22 +699,22 @@ export function Step5CargoDetails({
|
||||
name={`containers.${index}.hazardousQty`}
|
||||
control={form.control}
|
||||
render={({ field: hq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many hazardous?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={hq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
hq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={hq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
<div>
|
||||
<UnitCountToggles
|
||||
total={lineMax(index)}
|
||||
value={hq.value ?? "0"}
|
||||
onChange={hq.onChange}
|
||||
label="Tap the hazardous containers"
|
||||
activeBg="#FBEAE7"
|
||||
activeBorder="#E4A69B"
|
||||
activeColor="#C0392B"
|
||||
/>
|
||||
{fieldState.error?.message ? (
|
||||
<Text fz={11} c="red.7" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
@@ -681,7 +735,7 @@ export function Step5CargoDetails({
|
||||
reeField.onChange(v);
|
||||
form.setValue(
|
||||
`containers.${index}.reeferQty`,
|
||||
v ? defaultLineQty(index) : "0",
|
||||
v ? "1" : "0",
|
||||
{ shouldDirty: true, shouldValidate: true },
|
||||
);
|
||||
}}
|
||||
@@ -690,22 +744,22 @@ export function Step5CargoDetails({
|
||||
name={`containers.${index}.reeferQty`}
|
||||
control={form.control}
|
||||
render={({ field: rq, fieldState }) => (
|
||||
<TextInput
|
||||
type="number"
|
||||
size="sm"
|
||||
label="How many refrigerated?"
|
||||
min={1}
|
||||
max={lineMax(index)}
|
||||
value={rq.value ?? ""}
|
||||
onChange={(e) =>
|
||||
rq.onChange(
|
||||
clampToLine(e.currentTarget.value, index),
|
||||
)
|
||||
}
|
||||
onBlur={rq.onBlur}
|
||||
error={fieldState.error?.message}
|
||||
radius="md"
|
||||
/>
|
||||
<div>
|
||||
<UnitCountToggles
|
||||
total={lineMax(index)}
|
||||
value={rq.value ?? "0"}
|
||||
onChange={rq.onChange}
|
||||
label="Tap the refrigerated containers"
|
||||
activeBg="#E9F0F8"
|
||||
activeBorder="#A9C2E0"
|
||||
activeColor="#2E5B96"
|
||||
/>
|
||||
{fieldState.error?.message ? (
|
||||
<Text fz={11} c="red.7" mt={4}>
|
||||
{fieldState.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</ToggleRow>
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
RingProgress,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Tabs,
|
||||
@@ -211,6 +213,17 @@ export default function ContractDetailPage() {
|
||||
});
|
||||
const bookingWindowOpen = hasOpenWindow(bookingWindows);
|
||||
|
||||
// Draw-down capacity per cargo line (GENERAL contracts only). The backend
|
||||
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships
|
||||
// releases its share and the tracker fills back up. Refetched on window focus so
|
||||
// it reflects newly created / cancelled shipments.
|
||||
const { data: capacityLines = [] } = useQuery({
|
||||
queryKey: ["contract-capacity", id],
|
||||
queryFn: () => contractsService.getCapacity(id!),
|
||||
enabled: !!id && contract?.contractKind === "GENERAL",
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
|
||||
const contractBookings = useMemo(
|
||||
() =>
|
||||
(bookingsPage?.items ?? []).filter(
|
||||
@@ -882,6 +895,88 @@ export default function ContractDetailPage() {
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
{/* Draw-down capacity — GENERAL contracts with a per-line quantity cap.
|
||||
Fills as shipments consume capacity; empties again when a shipment is
|
||||
cancelled/rejected/expired (backend releases it). */}
|
||||
{isGeneral && capacityLines.length > 0 && (
|
||||
<Card
|
||||
withBorder
|
||||
radius="lg"
|
||||
p="lg"
|
||||
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
|
||||
>
|
||||
<SectionLabel mb="md">Contract capacity</SectionLabel>
|
||||
<Stack gap="lg">
|
||||
{capacityLines.map((line, i) => {
|
||||
const cap = line.cap ?? 0;
|
||||
const booked = line.booked ?? 0;
|
||||
const remaining = line.remaining ?? Math.max(0, cap - booked);
|
||||
const usedPct = cap > 0 ? Math.min(100, (booked / cap) * 100) : 0;
|
||||
const remainingPct = cap > 0 ? Math.round((remaining / cap) * 100) : 0;
|
||||
const unit = capacityUnitLabel(contract, line);
|
||||
const label = isContainer
|
||||
? `${line.containerSize ?? "Containers"}`
|
||||
: (contract.cargoScope ?? []).find(
|
||||
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||
)?.cargoType?.cargoTypeName ??
|
||||
(contract.cargoScope ?? [])[0]?.cargoFreeText ??
|
||||
"Bulk commodity";
|
||||
return (
|
||||
<Group
|
||||
key={line.containerSize ?? line.cargoTypeId ?? i}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
gap="lg"
|
||||
>
|
||||
<RingProgress
|
||||
size={72}
|
||||
thickness={8}
|
||||
roundCaps
|
||||
sections={[
|
||||
{
|
||||
value: remainingPct,
|
||||
color: remaining === 0 ? "red" : GREEN,
|
||||
},
|
||||
]}
|
||||
label={
|
||||
<Text ta="center" fz={13} fw={700} style={{ color: INK }}>
|
||||
{remainingPct}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group justify="space-between" mb={6} wrap="nowrap">
|
||||
<Group gap={8} wrap="nowrap">
|
||||
{isContainer ? (
|
||||
<Package size={16} color={MUTED} />
|
||||
) : (
|
||||
<Weight size={16} color={MUTED} />
|
||||
)}
|
||||
<Text fz={14} fw={600} style={{ color: INK }}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed">
|
||||
{booked} / {cap} {unit} booked
|
||||
</Text>
|
||||
</Group>
|
||||
<Progress
|
||||
value={usedPct}
|
||||
size="md"
|
||||
radius="xl"
|
||||
color={remaining === 0 ? "red" : GREEN}
|
||||
/>
|
||||
<Text fz={12} c="dimmed" mt={6}>
|
||||
{remaining} {unit} remaining
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Signatures */}
|
||||
{(contract.signatures ?? []).length > 0 && (
|
||||
<Card
|
||||
@@ -1351,6 +1446,22 @@ function SectionLabel({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unit noun for a capacity line: "containers" for CONTAINER freight, else the
|
||||
* bulk cargo's unit of measure ("tons" for PER_TON, "items" for PER_ITEM).
|
||||
*/
|
||||
function capacityUnitLabel(
|
||||
contract: Freight.IContract,
|
||||
line: Freight.ContractCapacityLine,
|
||||
): string {
|
||||
if (contract.freightType === "CONTAINER") return "containers";
|
||||
const scope =
|
||||
(contract.cargoScope ?? []).find(
|
||||
(s) => s.cargoTypeId === line.cargoTypeId,
|
||||
) ?? (contract.cargoScope ?? [])[0];
|
||||
return scope?.cargoType?.unitOfMeasure === "PER_ITEM" ? "items" : "tons";
|
||||
}
|
||||
|
||||
/**
|
||||
* One document row in the Documents tab: the file's kind (passport, business
|
||||
* license, contract, …) derived from its `code` as the primary label, the
|
||||
|
||||
@@ -526,23 +526,13 @@ export default function NewContractPage({
|
||||
},
|
||||
];
|
||||
|
||||
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
||||
// extras only apply to GENERAL contracts.
|
||||
// Route — a single origin→destination lane, general contracts included.
|
||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||
{
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
sortOrder: 0,
|
||||
},
|
||||
...(isGeneral
|
||||
? (data.extraRoutes ?? [])
|
||||
.filter((r) => r.originYard && r.destinationYard)
|
||||
.map((r, i) => ({
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
sortOrder: i + 1,
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -52,13 +52,9 @@ export function contractToFormValues(
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
// Contracts carry a single route now; older multi-route GENERAL contracts
|
||||
// load only their primary route.
|
||||
const primaryRoute = routes[0];
|
||||
const extraRoutes = isGeneral
|
||||
? routes.slice(1).map((r) => ({
|
||||
originYard: r.originYardId,
|
||||
destinationYard: r.destinationYardId,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const scope = contract.cargoScope ?? [];
|
||||
|
||||
@@ -131,7 +127,6 @@ export function contractToFormValues(
|
||||
|
||||
originYard: primaryRoute?.originYardId ?? "",
|
||||
destinationYard: primaryRoute?.destinationYardId ?? "",
|
||||
extraRoutes,
|
||||
|
||||
documents: {},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
|
||||
{
|
||||
value: "general_contract",
|
||||
label: "General Contract",
|
||||
description: "Ship multiple times over the validity window across routes.",
|
||||
description: "Ship multiple times over the validity window on one route.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
|
||||
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
|
||||
|
||||
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
|
||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
|
||||
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
|
||||
// letting them fail validation and silently block the Cargo & Route step.
|
||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field);
|
||||
// coerce those to 0 so the superRefine below can flag them with a clear
|
||||
// "greater than 0" message instead of a type error.
|
||||
const nonNegativeQuantityCap = z.preprocess(
|
||||
(v) =>
|
||||
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
|
||||
@@ -167,34 +167,23 @@ export const contractFormSchema = z
|
||||
// contract_cargo_scope row.
|
||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
|
||||
// GENERAL only: per-size container quantity cap (total bookable over the
|
||||
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
|
||||
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
|
||||
// an untouched cap never blocks the step.
|
||||
// validity window). Keyed by size; must be > 0 for every enabled size
|
||||
// (enforced in the superRefine below).
|
||||
containerSizeCaps: z
|
||||
.record(z.string(), nonNegativeQuantityCap)
|
||||
.default({}),
|
||||
// Bulk scope: the cargo type path (group → commodity).
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
cargoFreeText: z.string().default(""),
|
||||
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
|
||||
// field coercion as the container caps above.
|
||||
// GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine).
|
||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||
// Contract-level billing flags.
|
||||
isHazardous: z.boolean().default(false),
|
||||
isRefrigerated: z.boolean().default(false),
|
||||
|
||||
// ── Route ──
|
||||
// ── Route ── (one route per contract — general contracts included)
|
||||
originYard: z.string().min(1, "Select an origin yard."),
|
||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||
// Additional routes for a GENERAL contract (route #1 is the primary above).
|
||||
extraRoutes: z
|
||||
.array(
|
||||
z.object({
|
||||
originYard: z.string().default(""),
|
||||
destinationYard: z.string().default(""),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string().default(""),
|
||||
@@ -260,6 +249,29 @@ export const contractFormSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
|
||||
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
|
||||
// Cargo & Route step until the customer enters a quantity.
|
||||
if (data.contractKind === "general_contract") {
|
||||
if (data.cargoType === "container") {
|
||||
for (const size of data.enabledContainerSizes) {
|
||||
if (!(data.containerSizeCaps[size] > 0)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containerSizeCaps", size],
|
||||
message: `Enter a ${size} quantity greater than 0.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bulkQuantityCap"],
|
||||
message: "Enter a total quantity greater than 0.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||
@@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
extraRoutes: [],
|
||||
|
||||
documents: {},
|
||||
notes: "",
|
||||
@@ -325,7 +336,6 @@ export const contractStepFields: Record<
|
||||
"isRefrigerated",
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"extraRoutes",
|
||||
],
|
||||
// Step 2 — Review & Submit. (The separate Documents step was removed — the
|
||||
// company profile documents are attached to the contract automatically.)
|
||||
|
||||
@@ -132,19 +132,12 @@ export function Step1ContractType({
|
||||
);
|
||||
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
||||
|
||||
// ── Route (primary + extras) ──
|
||||
// ── Route (single route per contract) ──
|
||||
const routes = contract.routes ?? [];
|
||||
if (routes[0]) {
|
||||
form.setValue("originYard", routes[0].originYardId);
|
||||
form.setValue("destinationYard", routes[0].destinationYardId);
|
||||
}
|
||||
form.setValue(
|
||||
"extraRoutes",
|
||||
routes.slice(1).map((r) => ({
|
||||
originYard: r.originYardId,
|
||||
destinationYard: r.destinationYardId,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Cargo scope ──
|
||||
form.setValue(
|
||||
|
||||
@@ -227,14 +227,14 @@ export function Step3CargoScope({
|
||||
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
||||
{isGeneral && (
|
||||
<Box>
|
||||
<StepLabel>Booking quantity cap (optional)</StepLabel>
|
||||
<StepLabel>Booking quantity cap *</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||
Total quantity bookable across all shipments under this contract.
|
||||
Customers / GL can book repeatedly until it is reached. Leave 0 for
|
||||
unlimited.
|
||||
Customers / GL can book repeatedly until it is reached. Must be
|
||||
greater than 0.
|
||||
</Text>
|
||||
{cargoType === "container" ? (
|
||||
<Group gap={12} grow>
|
||||
<Group gap={12} grow align="flex-start">
|
||||
{enabledSizes.length === 0 ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
Select container sizes above to set their caps.
|
||||
@@ -245,13 +245,14 @@ export function Step3CargoScope({
|
||||
key={size}
|
||||
name={`containerSizeCaps.${size}`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
label={`${size} cap (containers)`}
|
||||
placeholder="0 = unlimited"
|
||||
label={`${size} cap (containers) *`}
|
||||
placeholder="e.g. 100"
|
||||
min={0}
|
||||
value={Number(field.value ?? 0)}
|
||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -264,13 +265,14 @@ export function Step3CargoScope({
|
||||
<Controller
|
||||
name="bulkQuantityCap"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
label="Total cap (tons / items)"
|
||||
placeholder="0 = unlimited"
|
||||
label="Total cap (tons / items) *"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
value={Number(field.value ?? 0)}
|
||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { MapPin, Plus, Trash2 } from "lucide-react";
|
||||
import { Skeleton, Stack } from "@mantine/core";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { getRouteDirection } from "./helpers";
|
||||
import { SelectField, StepLabel } from "./shared";
|
||||
@@ -29,7 +25,6 @@ export function Step4Route({
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const operationType = form.watch("operationType");
|
||||
const isGeneralContract = form.watch("contractKind") === "general_contract";
|
||||
|
||||
const { originCountry, destinationCountry } = useMemo(() => {
|
||||
switch (operationType) {
|
||||
@@ -46,14 +41,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [operationType]);
|
||||
|
||||
const {
|
||||
fields: extraRoutes,
|
||||
append: appendRoute,
|
||||
remove: removeRoute,
|
||||
} = useFieldArray({ control: form.control, name: "extraRoutes" });
|
||||
|
||||
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||
@@ -96,22 +83,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [destinationCountry, dest, form]);
|
||||
|
||||
useEffect(() => {
|
||||
watchedExtraRoutes.forEach((route, i) => {
|
||||
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
|
||||
if (originCountry && ro && ro.country !== originCountry) {
|
||||
form.setValue(`extraRoutes.${i}.originYard`, "");
|
||||
}
|
||||
const rd = referenceData?.yard.find(
|
||||
(y) => y.id === route?.destinationYard,
|
||||
);
|
||||
if (destinationCountry && rd && rd.country !== destinationCountry) {
|
||||
form.setValue(`extraRoutes.${i}.destinationYard`, "");
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [originCountry, destinationCountry, referenceData, form]);
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
@@ -173,94 +144,6 @@ export function Step4Route({
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGeneralContract && !isLoading && (
|
||||
<Box mt={18}>
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<StepLabel>Additional contract routes</StepLabel>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Plus size={14} />}
|
||||
disabled={stationSelectDisabled}
|
||||
onClick={() =>
|
||||
appendRoute({ originYard: "", destinationYard: "" })
|
||||
}
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz={12} c="#6B7C8E" mb={12}>
|
||||
A general contract can cover several routes. The route above is your
|
||||
primary route; add more origin–destination routes the contract
|
||||
should cover.
|
||||
</Text>
|
||||
<Stack gap={12}>
|
||||
{extraRoutes.map((rf, i) => {
|
||||
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
|
||||
const rowDestination =
|
||||
watchedExtraRoutes[i]?.destinationYard ?? "";
|
||||
const rowOriginData = yardsForSide(originCountry, rowDestination);
|
||||
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
|
||||
return (
|
||||
<Group
|
||||
key={rf.id}
|
||||
gap={10}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.originYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin"
|
||||
placeholder="Origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowOriginData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.destinationYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination"
|
||||
placeholder="Destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowDestData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
mt={24}
|
||||
px={6}
|
||||
onClick={() => removeRoute(i)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,10 +250,6 @@ export function Step8Review({
|
||||
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
||||
: "—";
|
||||
|
||||
const routesCount = 1 + (values.extraRoutes?.filter(
|
||||
(r) => r.originYard && r.destinationYard,
|
||||
).length ?? 0);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<StepHeader
|
||||
@@ -335,17 +331,13 @@ export function Step8Review({
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Route size={18} />}
|
||||
label="Primary route"
|
||||
label="Route"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<MapPin size={18} />}
|
||||
label="Trade direction"
|
||||
value={
|
||||
isGeneralContract
|
||||
? `${directionLabel} · ${routesCount} routes`
|
||||
: directionLabel
|
||||
}
|
||||
value={directionLabel}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
|
||||
@@ -66,6 +66,8 @@ import type {
|
||||
import type { ProfileResponse, UpdateProfilePayload } from "@/types/profile";
|
||||
import type {
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
@@ -107,6 +109,11 @@ export const api = {
|
||||
"setPassword",
|
||||
authService.setPassword,
|
||||
),
|
||||
checkAvailability: endpoint<CheckAvailabilityPayload, CheckAvailabilityResponse>(
|
||||
"auth",
|
||||
"checkAvailability",
|
||||
authService.checkAvailability,
|
||||
),
|
||||
sendOTP: endpoint<OtpPayload, OtpResponse>(
|
||||
"auth",
|
||||
"sendOTP",
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import type {
|
||||
AuthUser,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
AuthUser,
|
||||
CheckAvailabilityPayload,
|
||||
CheckAvailabilityResponse,
|
||||
GenerateVerificationCodePayload,
|
||||
LoginPayload,
|
||||
LoginResponse,
|
||||
OtpPayload,
|
||||
OtpResponse,
|
||||
SetPasswordPayload,
|
||||
SignupPayload,
|
||||
SignupResponse,
|
||||
} from "@/types/auth";
|
||||
import { client } from "@/utils/api";
|
||||
import { ApiResponse } from "@edr/types";
|
||||
@@ -23,7 +25,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
createUser: async (body: SignupPayload) => {
|
||||
const res = await client.post<SignupResponse & ApiResponse<void>> (
|
||||
const res = await client.post<SignupResponse & ApiResponse<void>>(
|
||||
URL_CONSTANTS.USERS.SIGN_UP,
|
||||
body,
|
||||
);
|
||||
@@ -31,9 +33,7 @@ export const authService = {
|
||||
},
|
||||
|
||||
getMyInfo: async () => {
|
||||
const res = await client.get<AuthUser>(
|
||||
URL_CONSTANTS.USERS.ME,
|
||||
);
|
||||
const res = await client.get<AuthUser>(URL_CONSTANTS.USERS.ME);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
@@ -53,6 +53,14 @@ export const authService = {
|
||||
return res.data.data;
|
||||
},
|
||||
|
||||
checkAvailability: async (params: CheckAvailabilityPayload) => {
|
||||
const res = await client.get<CheckAvailabilityResponse>(
|
||||
URL_CONSTANTS.USERS.CHECK_AVAILABILITY,
|
||||
{ params },
|
||||
);
|
||||
return res.data;
|
||||
},
|
||||
|
||||
sendOTP: async (body: OtpPayload) => {
|
||||
const res = await client.post<ApiResponse<OtpResponse>>(
|
||||
URL_CONSTANTS.OTP.SEND,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
|
||||
const B = URL_CONSTANTS.BOOKINGS;
|
||||
|
||||
/**
|
||||
* Multi-truck self-haul assignment for a booking (no EDR first/last mile).
|
||||
* Each truck carries 1–2 of the booking's containers and tracks its own arrival.
|
||||
*/
|
||||
export const customerTrucksService = {
|
||||
list: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
add: async (
|
||||
bookingId: string,
|
||||
payload: Freight.AddCustomerTruckPayload,
|
||||
): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
remove: async (
|
||||
bookingId: string,
|
||||
assignmentId: string,
|
||||
): Promise<Freight.ICustomerTruck[]> => {
|
||||
const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId));
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,10 @@
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { client } from "../utils/api";
|
||||
import type {
|
||||
InitiateResponse,
|
||||
PaymentMethod,
|
||||
PaymentPlatform,
|
||||
} from "./payments.service";
|
||||
|
||||
const W = URL_CONSTANTS.WAREHOUSE_INVOICES;
|
||||
|
||||
@@ -51,4 +56,27 @@ export const warehouseInvoicesService = {
|
||||
const { data } = await client.get(W.RECEIPT(id), { responseType: "blob" });
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initiate a Telebirr/Waafi online payment for a warehouse demurrage/storage
|
||||
* invoice. Returns the payment intent + `clientAction` to redirect the browser
|
||||
* to the provider (mirrors the booking `/pay` flow). The backoffice cash
|
||||
* `/pay` (record-a-payment) path is unaffected.
|
||||
*/
|
||||
payOnline: async (
|
||||
id: string,
|
||||
payload: {
|
||||
method: PaymentMethod;
|
||||
platform?: PaymentPlatform;
|
||||
payerAccount?: string;
|
||||
returnUrl?: string;
|
||||
failureUrl?: string;
|
||||
},
|
||||
): Promise<InitiateResponse> => {
|
||||
const { data } = await client.post(W.PAY_ONLINE(id), {
|
||||
platform: "web",
|
||||
...payload,
|
||||
});
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,6 +45,16 @@ export interface OtpResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityPayload {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface CheckAvailabilityResponse {
|
||||
emailTaken: boolean;
|
||||
phoneTaken: boolean;
|
||||
}
|
||||
|
||||
export interface SetPasswordPayload {
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
|
||||
Reference in New Issue
Block a user