Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-06 12:06:22 +00:00
300 changed files with 17177 additions and 5298 deletions

View File

@@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

View File

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

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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"

View File

@@ -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>

View File

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

View File

@@ -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 &amp; 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 &amp; 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>
);

View File

@@ -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

View File

@@ -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>

View File

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

View File

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

View File

@@ -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>

View File

@@ -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) =>

View File

@@ -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) =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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({

View File

@@ -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) =>

View File

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

View File

@@ -27,7 +27,6 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},

View File

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

View File

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

View File

@@ -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>

View File

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

View File

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

View File

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

View File

@@ -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" },
],

View File

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

View File

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

View File

@@ -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"

View File

@@ -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

View File

@@ -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,

View File

@@ -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> => {

View File

@@ -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[];
}

View File

@@ -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"

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

View File

@@ -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 />}

View File

@@ -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

View File

@@ -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 EthioDjibouti 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 &amp; 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 &amp; 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>

View File

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

View File

@@ -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] : [];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>

View File

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

View File

@@ -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&apos;t have an account?{" "}
@@ -129,7 +115,7 @@ export default function LoginPage() {
Create an account
</button>
</p>
</div>
</Stack>
</form>
</AuthShell>
);

View File

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

View File

@@ -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 (12)"
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>
);

View File

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

View File

@@ -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 AZ" },
{ value: "reference:DESC", label: "Reference ZA" },
] 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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -526,23 +526,13 @@ export default function NewContractPage({
},
];
// Routespure 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 {

View File

@@ -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: {},
};

View File

@@ -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.)

View File

@@ -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(

View File

@@ -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}
/>

View File

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

View File

@@ -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} />}

View File

@@ -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",

View File

@@ -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,

View File

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

View File

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

View File

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