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

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