Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-20 08:20:34 +00:00
178 changed files with 11076 additions and 3168 deletions

View File

@@ -846,6 +846,16 @@ const App = () => {
</RequirePermission>
}
/>
<Route
path="contracts/clearance-documents/:id"
element={
<RequirePermission
permission={FREIGHT_PERMS.contracts.opsClearanceReview}
>
<ContractClearanceDetailPage />
</RequirePermission>
}
/>
{/* GL (Path B) contract clearance review hub */}
<Route
path="contracts/clearance"

View File

@@ -15,10 +15,23 @@ import {
} from "./cookies";
import type { AuthTokens } from "./types";
declare module "axios" {
export interface AxiosRequestConfig {
/**
* When true, the response interceptor does NOT raise the global error modal
* for this request's failure. For calls the caller handles itself — e.g. a
* probe that is expected to 404 before falling back (GL clearance detail
* tries /contracts/:id then /bookings/:id). The rejection still propagates.
*/
suppressErrorModal?: boolean;
}
}
type RetriableRequest = {
_retry?: boolean;
headers?: Record<string, string>;
url?: string;
suppressErrorModal?: boolean;
};
const api = axios.create({
@@ -100,8 +113,13 @@ api.interceptors.response.use(
originalRequest.url?.includes("/auth/refresh-token")
) {
// Surface the server's actual error message in the global error modal
// (401s are handled by the session-refresh flow, so skip them).
if (error.response && error.response.status !== 401) {
// (401s are handled by the session-refresh flow, so skip them). A request
// may opt out via `suppressErrorModal` when it handles the failure itself.
if (
error.response &&
error.response.status !== 401 &&
!originalRequest?.suppressErrorModal
) {
const payload = extractApiErrorPayload(error);
if (payload) emitApiError(payload);
}

View File

@@ -0,0 +1,43 @@
import { Link } from "react-router-dom";
/**
* The parent contract's reference, linking to that contract's detail page.
*
* Backoffice-local on purpose: the contract detail route differs per app
* (`/dashboard/contract-requests/:id` here vs `/contracts/:id` in the portal),
* so the portal keeps its own copy in `pages/bookings/booking-display.tsx`
* rather than the two sharing a component that would have to take the route as
* a prop at every call site.
*
* Renders nothing when either field is missing: `contractId` is nullable on the
* booking, and only the bookings list/detail endpoints join `contractReference`
* — other endpoints (warehouse, fleet, payments) return booking rows without it,
* and a link with no id would be a dead one.
*
* `stopPropagation` matters: booking rows are click-to-navigate, so without it a
* click here would race the row handler and land on the booking instead.
*/
export function ContractReferenceLink({
contractId,
contractReference,
className,
}: {
contractId?: string | null;
contractReference?: string | null;
className?: string;
}) {
if (!contractId || !contractReference) return null;
return (
<Link
to={`/dashboard/contract-requests/${contractId}`}
onClick={(e) => e.stopPropagation()}
className={
className ??
"block truncate font-mono text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
}
>
{contractReference}
</Link>
);
}

View File

@@ -24,6 +24,7 @@ import type { LucideIcon } from "lucide-react";
import type { BookingDetail } from "@/types/booking";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import { BookingPriorityBadge } from "@/components/bookings/BookingPriorityBadge";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { SchedulingStatusBadge } from "@/components/trainScheduling/ScheduleStatusBadge";
import { NextStepBanner } from "@/components/bookings/NextStepBanner";
@@ -94,9 +95,15 @@ export function BookingRequestHero({
Booking reference
</Text>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<Stack gap={2} miw={0}>
<Title order={2} fw={700} style={{ letterSpacing: "-0.4px" }}>
{booking.reference}
</Title>
<ContractReferenceLink
contractId={booking.contractId}
contractReference={booking.contractReference}
/>
</Stack>
<BookingStatusBadge status={booking.status} />
<BookingPriorityBadge score={booking.priorityScore} />
{booking.schedulingStatus ? (

View File

@@ -4,6 +4,7 @@ import {
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query";
@@ -37,10 +38,12 @@ import {
FileDown,
FileText,
FileUp,
Flame,
MapPin,
Package,
Receipt,
Repeat,
Snowflake,
X,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -128,6 +131,10 @@ interface UnitDraft {
containerNumber: string;
sealNumber: string;
vgmTons: string;
/** Handling is per physical container; the line counts roll these up. */
isHazardous: boolean;
isReefer: boolean;
isReturn: boolean;
}
/** Mirrors the portal shipment form's container line: line-level quantity +
@@ -150,7 +157,14 @@ interface BulkDraft {
}
function emptyUnit(): UnitDraft {
return { containerNumber: "", sealNumber: "", vgmTons: "" };
return {
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
};
}
function emptyLine(size: string): ContainerLineDraft {
@@ -285,6 +299,41 @@ export default function GlCreateBookingForm() {
// Legacy contracts (no equipment return chosen at creation) keep the old
// booking-level toggle.
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
/**
* Handling switches offered on each container row — only the services this
* contract was created with, since the server rejects the others.
*/
const handlingColumns = (
[
contract?.isHazardous && {
key: "isHazardous",
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
contract?.isReefer && {
key: "isReefer",
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
contractWithReturn && {
key: "isReturn",
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
] as Array<
| false
| undefined
| { key: keyof UnitDraft; label: string; icon: ReactNode; color: string }
>
).filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
icon: ReactNode;
color: string;
}>;
// Intercity shipments ride a passing import/export train staff pick at
// finalize time — no shipment day is chosen and no window gate applies.
const isIntercity = contract?.tradeDirection === "DOMESTIC";
@@ -358,6 +407,9 @@ export default function GlCreateBookingForm() {
useEffect(() => {
if (!bookingRequest || prefilled) return;
// A rebook (?copyFrom=) seeds from the expired booking's real cargo —
// richer than the request's bare quantities. Let that seed win the race.
if (copyFromParam) return;
setPrefilled(true);
const lines = bookingRequest.requestedLines ?? {};
if (lines.containers?.length) {
@@ -399,13 +451,27 @@ export default function GlCreateBookingForm() {
setContainerLines(
lines.map((c) => {
const qty = Math.max(1, c.quantity);
// Carry the persisted per-unit details (numbers, seals, VGM, handling)
// when the source booking has them — a rebooked EXPIRED booking does,
// and its cargo is fixed server-side anyway.
const units: UnitDraft[] =
c.units?.length === qty
? c.units.map((u) => ({
containerNumber: u.containerNumber ?? "",
sealNumber: u.sealNumber ?? "",
vgmTons: u.vgmTons != null ? String(u.vgmTons) : "",
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
isReturn: Boolean(u.isReturn),
}))
: Array.from({ length: qty }, emptyUnit);
return {
containerSize: String(c.containerType?.sizeFt ?? ""),
quantity: String(qty),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: qty }, emptyUnit),
hazardousQuantity: String(units.filter((u) => u.isHazardous).length),
reeferQuantity: String(units.filter((u) => u.isReefer).length),
returnQuantity: String(units.filter((u) => u.isReturn).length),
units,
};
}),
);
@@ -488,6 +554,18 @@ export default function GlCreateBookingForm() {
enabled: cargoQuery !== null && !isIntercity,
});
/**
* Line handling totals are a roll-up of the per-container switches — the
* count is however many containers ticked each service. Recomputed on every
* unit change so the price estimate and payload follow the switches.
*/
const withDerivedCounts = (line: ContainerLineDraft): ContainerLineDraft => ({
...line,
hazardousQuantity: String(line.units.filter((u) => u.isHazardous).length),
reeferQuantity: String(line.units.filter((u) => u.isReefer).length),
returnQuantity: String(line.units.filter((u) => u.isReturn).length),
});
// Keep the units array length in sync with the entered quantity.
const syncUnits = (lineIdx: number, qty: number) => {
setContainerLines((prev) =>
@@ -496,7 +574,7 @@ export default function GlCreateBookingForm() {
const next = [...line.units];
while (next.length < qty) next.push(emptyUnit());
next.length = Math.max(0, qty);
return { ...line, units: next };
return withDerivedCounts({ ...line, units: next });
}),
);
};
@@ -511,11 +589,16 @@ export default function GlCreateBookingForm() {
unitIdx: number,
patch: Partial<UnitDraft>,
) =>
patchLine(lineIdx, {
units: containerLines[lineIdx].units.map((u, i) =>
i === unitIdx ? { ...u, ...patch } : u,
setContainerLines((prev) =>
prev.map((l, i) =>
i === lineIdx
? withDerivedCounts({
...l,
units: l.units.map((u, j) => (j === unitIdx ? { ...u, ...patch } : u)),
})
: l,
),
});
);
// Same client-side validation as the customer portal shipment form
// (new-shipment-form/schema.ts): ISO container numbers unique within the
@@ -560,10 +643,15 @@ export default function GlCreateBookingForm() {
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: String(r.vgmTons),
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
}),
@@ -742,6 +830,11 @@ export default function GlCreateBookingForm() {
containerNumber: u.containerNumber.trim().toUpperCase(),
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
vgmTons: Number(u.vgmTons) || 0,
// Per-container handling — the server rolls these into the line
// counts and bills each surcharge on the ticked containers only.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(contractWithReturn ? { isReturn: Boolean(u.isReturn) } : {}),
})),
}));
} else {
@@ -1175,78 +1268,60 @@ export default function GlCreateBookingForm() {
radius={10}
styles={fieldStyles}
/>
{contract.isHazardous && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Hazardous qty"
min={0}
value={line.hazardousQuantity}
error={
showErrors
? lineErrors[lineIdx]?.hazardousQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
hazardousQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contract.isReefer && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="Reefer qty"
min={0}
value={line.reeferQuantity}
error={
showErrors
? lineErrors[lineIdx]?.reeferQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
reeferQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
{contractWithReturn && (
<TextInput
type="number"
onKeyDown={blockNegative}
label="With return qty"
description="Containers EDR returns empty"
min={0}
value={line.returnQuantity}
error={
showErrors
? lineErrors[lineIdx]?.returnQuantity
: undefined
}
onChange={(e) =>
patchLine(lineIdx, {
returnQuantity: e.currentTarget.value,
})
}
radius={10}
styles={fieldStyles}
/>
)}
</Group>
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 ? (
<Text fz={11} c="dimmed" mt={4}>
Tick the services each individual container needs
charges apply only to the containers ticked
{handlingColumns
.map((col) => {
const count = line.units.filter(
(u) => u[col.key],
).length;
return count > 0 ? ` · ${count} ${col.label.toLowerCase()}` : "";
})
.join("")}
.
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels,
one aligned grid shared by every unit row below.
Same layout as the portal shipment form. */}
{line.units.length > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{line.units.map((unit, unitIdx) => (
<Group key={unitIdx} gap={10} grow align="flex-start">
<Group key={unitIdx} gap={10} wrap="nowrap" align="flex-start">
<TextInput
label={unitIdx === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
value={unit.containerNumber}
error={
@@ -1262,9 +1337,9 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
label={unitIdx === 0 ? "Seal number" : undefined}
placeholder="Optional"
value={unit.sealNumber}
onChange={(e) =>
@@ -1274,11 +1349,11 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
<TextInput
type="number"
onKeyDown={blockNegative}
label={unitIdx === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
@@ -1295,7 +1370,32 @@ export default function GlCreateBookingForm() {
}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
{handlingColumns.map((col) => (
<Box
key={col.key}
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(unit[col.key])}
aria-label={`${col.label} — container ${unitIdx + 1}`}
onChange={(e) =>
patchUnit(lineIdx, unitIdx, {
[col.key]: e.currentTarget.checked,
})
}
size="sm"
/>
</Box>
))}
</Group>
))}
</Stack>

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
Alert,
Badge,
@@ -72,6 +72,7 @@ export type ClearanceViewLike = Pick<
| "linkedBookingId"
| "riskLevel"
| "riskAssignedAt"
| "riskHistory"
| "secondDuty"
| "importReleaseGranted"
> & { operationReady?: boolean };
@@ -1040,11 +1041,28 @@ function RiskStep({
done: boolean;
onChanged?: () => void;
}) {
const [level, setLevel] = useState<string>("GREEN");
const assigned = done || Boolean(clearance.riskLevel);
// Duty is advised off the risk level, so once that is done the decision is
// final. Until then a mis-assigned level must stay correctable — the server
// overwrites the milestone metadata on reassignment. Mirrors AssignRiskCard.
const locked = isMilestoneDone(clearance.milestones, "DUTY_TAXES_ADVISED");
const [level, setLevel] = useState<string>(clearance.riskLevel ?? "GREEN");
const [loading, setLoading] = useState(false);
if (done || clearance.riskLevel) {
return (
// The clearance view loads (and refetches after a reassignment) after first
// render, so mirror the persisted level onto the control whenever it changes —
// otherwise reopening the step offers GREEN whatever is actually assigned.
useEffect(() => {
if (clearance.riskLevel) setLevel(clearance.riskLevel);
}, [clearance.riskLevel]);
// Only the decisions before the current one — the badge above already states
// the level in force, so repeating it as a trail entry reads as a duplicate.
const priorDecisions = (clearance.riskHistory ?? []).slice(0, -1);
const assignedSummary = assigned ? (
<Stack gap={6}>
<Group gap="sm">
<Badge
color={RISK_LEVEL_COLOR[clearance.riskLevel ?? ""] ?? "gray"}
@@ -1061,12 +1079,35 @@ function RiskStep({
. The customer can see this level.
</Text>
</Group>
);
{priorDecisions.length > 0 ? (
<Stack gap={2} pl="xs">
<Text size="xs" c="dimmed" fw={600}>
Previously
</Text>
{priorDecisions.map((entry, index) => (
<Text key={`${entry.assignedAt}-${index}`} size="xs" c="dimmed">
{entry.level}
{" · "}
{new Date(entry.assignedAt).toLocaleString()}
{entry.assignedBy ? ` · ${entry.assignedBy}` : ""}
{entry.note ? ` · ${entry.note}` : ""}
</Text>
))}
</Stack>
) : null}
</Stack>
) : null;
// Assigned and final: the badge is all that is left to show.
if (assigned && (locked || !canAct || !bookingId)) {
return assignedSummary;
}
// Customs cannot rate cargo still under transit — the server rejects the
// assignment until the T1 is closed, so do not offer the control yet.
if (!clearance.t1?.closed) {
// assignment until the T1 is closed, so do not offer the control yet. Skipped
// once a level exists: risk cannot have been assigned without a closed T1, so
// a still-open T1 here is stale data and must not hide the assigned badge.
if (!assigned && !clearance.t1?.closed) {
return (
<StepStatus
done={false}
@@ -1088,6 +1129,7 @@ function RiskStep({
return (
<Stack gap="sm">
{assignedSummary}
<SegmentedControl
fullWidth
value={level}
@@ -1100,19 +1142,24 @@ function RiskStep({
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
The customer sees the assigned risk level.
{assigned
? "Correctable until duty is advised. The customer sees the assigned risk level."
: "The customer sees the assigned risk level."}
</Text>
<Button
size="compact-sm"
color="edr-green"
loading={loading}
disabled={assigned && level === clearance.riskLevel}
onClick={async () => {
setLoading(true);
try {
await contractsService.assignRisk(bookingId, {
riskLevel: level as Freight.CustomsRiskLevel,
});
toast.success("Customs risk assigned");
toast.success(
assigned ? "Customs risk reassigned" : "Customs risk assigned",
);
onChanged?.();
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed");
@@ -1121,7 +1168,7 @@ function RiskStep({
}
}}
>
Assign risk
{assigned ? "Reassign risk" : "Assign risk"}
</Button>
</Group>
</Stack>

View File

@@ -104,6 +104,11 @@ export function ApiErrorModal() {
onClose={close}
centered
radius="md"
// Mounted at the app root, so its portal is FIRST in <body> — at the
// default z-index (200) any page modal opened later (create schedule,
// allocation wizard, …) paints over it and the error hides underneath.
// Hoist above every Mantine overlay and the react-hot-toast layer (9999).
zIndex={10000}
title={
<Group gap="xs">
<AlertTriangle size={18} color="var(--mantine-color-red-6)" />

View File

@@ -16,7 +16,8 @@ import { useEffect, useState } from "react";
import { api } from "@/services/api";
import type { TrainComposition } from "@/services/trainBuilder.service";
import { useToast } from "@/hooks/use-toast";
import { IMPORT_TRAIN_OPTIONS, exportRunFor } from "@/constants/trainRuns";
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
import { exportRunFor } from "@/constants/trainRuns";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
@@ -42,6 +43,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
const [notes, setNotes] = useState("");
const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 }));
// Admin-managed run list (dropdown settings); numbers already on a train
// come back disabled so they cannot be picked twice.
const importNumbers = useImportTrainNumberOptions();
// Only serviceable locomotives standing in the selected yard can be coupled.
const locomotivesQuery = useQuery(
api.locomotives.listFiltered.queryOptions({
@@ -150,12 +154,13 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
<Select
label="Import train number"
description="Even — Djibouti → Ethiopia runs"
placeholder="e.g. 8002"
data={IMPORT_TRAIN_OPTIONS}
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
data={importNumbers.options}
value={importTrainNumber || null}
onChange={(value) => setImportTrainNumber(value ?? "")}
searchable
clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
/>
</Group>
<Select

View File

@@ -1,9 +1,11 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import { Pencil } from "lucide-react";
import { useEffect, useState } from "react";
import { exportRunFor } from "@/constants/trainRuns";
import { useToast } from "@/hooks/use-toast";
import { useImportTrainNumberOptions } from "@/hooks/useImportTrainNumberOptions";
import { api } from "@/services/api";
import type { BuiltTrainSummary } from "@/services/trainBuilder.service";
@@ -15,9 +17,11 @@ export interface EditTrainDetailsModalProps {
/**
* Edit a built train's display identity from the list: its name and its fixed
* import/export run numbers. Composition (yard, locomotives, wagons) is edited
* on the detail page. Number collisions come back as a 409 with the owning
* train's code and surface verbatim.
* import/export run numbers. The import number comes from the admin-managed
* dropdown setting (numbers on other trains are disabled; this train's own
* number stays pickable) and the export number follows it. Composition (yard,
* locomotives, wagons) is edited on the detail page. Number collisions come
* back as a 409 with the owning train's code and surface verbatim.
*/
const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) => {
const { toast } = useToast();
@@ -25,6 +29,8 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
const [importNo, setImportNo] = useState("");
const [exportNo, setExportNo] = useState("");
const importNumbers = useImportTrainNumberOptions(train?.importTrainNumber);
useEffect(() => {
if (train) {
setName(train.trainName ?? "");
@@ -86,20 +92,29 @@ const EditTrainDetailsModal = ({ train, onClose }: EditTrainDetailsModalProps) =
radius="md"
/>
<Group grow>
<TextInput
<Select
label="Import train no."
placeholder="e.g. 8002"
value={importNo}
onChange={(e) => setImportNo(e.currentTarget.value)}
maxLength={20}
placeholder={importNumbers.isLoading ? "Loading…" : "e.g. 8002"}
data={importNumbers.options}
value={importNo || null}
onChange={(value) => {
// Clearing keeps the stored numbers (empty inputs are dropped on
// save); a pick re-derives the paired export run.
setImportNo(value ?? "");
setExportNo(value ? exportRunFor(value) : (train?.exportTrainNumber ?? ""));
}}
searchable
clearable
nothingFoundMessage="No free run numbers — add more in Dropdown Settings"
radius="md"
/>
<TextInput
label="Export train no."
description="Follows the import run"
placeholder="e.g. 8001"
value={exportNo}
onChange={(e) => setExportNo(e.currentTarget.value)}
maxLength={20}
readOnly
variant="filled"
radius="md"
/>
</Group>

View File

@@ -15,6 +15,8 @@ export const trainStatusColor = (status: BuiltTrainStatus | string): string => {
return "yellow";
case "OUT_OF_SERVICE":
return "red";
case "DEACTIVATED":
return "gray";
default:
return "gray";
}

View File

@@ -40,17 +40,21 @@ export function PreviewSummary({
summary?: {
totalBookings: number;
totalWeightTons: number;
grossWeightTons?: number;
totalTareTons?: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
}) {
if (!summary) return null;
// GROSS — the axis every train limit is spent against.
const gross = summary.grossWeightTons ?? summary.totalWeightTons;
const stats = [
{ label: "Bookings", value: String(summary.totalBookings) },
{ label: "Wagons", value: String(summary.wagonsNeeded) },
{ label: "Wagon type", value: summary.wagonType },
{ label: "Total weight", value: `${summary.totalWeightTons}T` },
{ label: "Gross weight", value: `${gross}T` },
{ label: "Train length", value: `${summary.totalLengthMeters}m` },
];
return (

View File

@@ -99,9 +99,8 @@ function usedWeight(schedule: TrainScheduleDetail): number {
/**
* Pull capacity of the set = the WEAKEST locomotive's max pull weight (0 when
* unknown). The API caps at the weakest loco, not the sum of all locos — a
* consist can only pull as hard as its weakest engine. Note: the API also adds
* the consist tare to the used weight when it checks this cap; tare isn't
* available client-side, so this meter compares cargo-only load against pull.
* consist can only pull as hard as its weakest engine. Both sides of this meter
* are gross: `usedWeight` sums per-booking gross (cargo + wagon tare).
*/
function pullCapacity(schedule: TrainScheduleDetail): number {
const set = schedule.trainSet;

View File

@@ -46,6 +46,8 @@ type NormalizedWagon = {
const CAR_WIDTH = 150; // car body + coupler footprint
const round1 = (n: number) => Math.round(n * 10) / 10;
function normalizeWagon(w: DiagramWagonInput, freightType?: string | null): NormalizedWagon {
const allocations = w.allocations ?? [];
const firstLoad = (
@@ -268,10 +270,11 @@ const CONTAINER_BORDERS = [
];
function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
// GROSS on both sides: cargo + tare vs rated payload + tare.
const grossTons = round1(wagon.assignedWeightTons + wagon.tareWeightTons);
const maxGrossTons = round1(wagon.capacityTons + wagon.tareWeightTons);
const utilization =
wagon.capacityTons > 0
? Math.min(100, Math.round((wagon.assignedWeightTons / wagon.capacityTons) * 100))
: 0;
maxGrossTons > 0 ? Math.min(100, Math.round((grossTons / maxGrossTons) * 100)) : 0;
const accent = wagon.isEmpty ? "gray" : wagon.isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;
@@ -281,8 +284,8 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
wagon.bookingRefs.length ? wagon.bookingRefs.join(", ") : ""
}${
wagon.containerNumbers.length ? `\nContainers: ${wagon.containerNumbers.join(", ")}` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nLoad: ${wagon.assignedWeightTons}/${wagon.capacityTons}T (${utilization}%)${
wagon.tareWeightTons ? `\nTare: ${wagon.tareWeightTons}T` : ""
}${wagon.cargoDescription ? `\n${wagon.cargoDescription}` : ""}\nGross: ${grossTons}/${maxGrossTons}T (${utilization}%)\nCargo: ${wagon.assignedWeightTons}T${
wagon.tareWeightTons ? ` · Tare: ${wagon.tareWeightTons}T` : ""
}`;
// container blocks: one per container number (cap visual at 2 = TEU per wagon)
@@ -369,7 +372,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
/>
</Box>
<Text size="9px" c="dimmed" ta="center" fw={600}>
{wagon.assignedWeightTons}/{wagon.capacityTons}T
{grossTons}/{maxGrossTons}T
</Text>
</Stack>
) : (
@@ -442,7 +445,7 @@ function WagonCar({ wagon }: { wagon: NormalizedWagon }) {
</Text>
{!wagon.isEmpty ? (
<Text size="8px" c="gray.6" fw={700} style={{ whiteSpace: "nowrap" }}>
{wagon.assignedWeightTons}T
{grossTons}T
</Text>
) : null}
</Group>

View File

@@ -6,6 +6,7 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
sequenceNo: number;
capacityTons: number;
assignedWeightTons: number;
tareWeightTons?: number | null;
slotLoadType?: string;
wagonType?: { code: string } | null;
wagonTypeCode?: string;
@@ -21,6 +22,8 @@ type WagonSlot = (WagonPlanRow & { physicalWagonNumber?: string | null }) | {
}>;
};
const round1 = (n: number) => Math.round(n * 10) / 10;
function loadTypeColor(loadType: string | undefined, freightType?: string | null) {
const normalized = loadType?.toUpperCase() ?? "";
if (normalized.includes("BULK")) return "orange";
@@ -68,8 +71,16 @@ export function WagonPlanGrid({
);
}
const totalCapacity = wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0);
const totalAssigned = wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0);
// GROSS on both sides: cargo + tare vs rated payload + tare.
const totalTare = round1(
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
);
const totalCapacity = round1(
wagonPlan.reduce((sum, w) => sum + w.capacityTons, 0) + totalTare,
);
const totalAssigned = round1(
wagonPlan.reduce((sum, w) => sum + w.assignedWeightTons, 0) + totalTare,
);
const usedSlots = wagonPlan.filter((w) => (w.allocations?.length ?? 0) > 0).length;
const isBulk = freightType === "BULK" || wagonPlan.every((w) => w.slotLoadType === "BULK" || (!w.slotLoadType && w.allocations?.[0]?.loadType === "Bulk"));
@@ -82,7 +93,7 @@ export function WagonPlanGrid({
</Text>
{isBulk ? (
<Text size="sm" c="dimmed">
Load: <strong>{totalAssigned}</strong> / {totalCapacity}T
Gross: <strong>{totalAssigned}</strong> / {totalCapacity}T
</Text>
) : null}
</Group>
@@ -90,8 +101,9 @@ export function WagonPlanGrid({
<SimpleGrid cols={{ base: 1, sm: 2, xl: 3 }} spacing="md">
{wagonPlan.map((wagon) => {
const seq = wagon.sequenceNo;
const capacity = wagon.capacityTons;
const assigned = wagon.assignedWeightTons;
const tare = Number(wagon.tareWeightTons) || 0;
const capacity = round1(wagon.capacityTons + tare);
const assigned = round1(wagon.assignedWeightTons + tare);
const allocations = wagon.allocations ?? [];
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const label = slotLabel(wagon, freightType);
@@ -149,7 +161,7 @@ export function WagonPlanGrid({
</Text>
{label === "BULK" ? (
<Text size="xs" c="dimmed">
{alloc.allocatedWeightTons}T
{alloc.allocatedWeightTons}T cargo
</Text>
) : null}
</Group>

View File

@@ -132,7 +132,7 @@ export const BookingDetailModal = ({
/>
<InfoRow
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
value={
<Text size="sm" fw={700}>
{booking.weightTons != null ? `${booking.weightTons.toFixed(1)} T` : "—"}

View File

@@ -161,8 +161,11 @@ function WagonCar({
const allocation = wagon.allocations?.[0];
const isEmpty = !allocation;
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const assigned = allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0;
const capacity = wagon.capacityTons ?? 0;
// GROSS on both sides: cargo + tare vs rated payload + tare.
const tare = wagon.tareWeightTons ?? 0;
const assigned =
(allocation?.allocatedWeightTons ?? wagon.assignedWeightTons ?? 0) + tare;
const capacity = (wagon.capacityTons ?? 0) + tare;
const utilization = capacity > 0 ? Math.min(100, Math.round((assigned / capacity) * 100)) : 0;
const accent = isEmpty ? "gray" : isBulk ? "orange" : "cyan";
const accentVar = `var(--mantine-color-${accent}-6)`;

View File

@@ -21,6 +21,8 @@ export const RemoveBookingModal = ({
if (!wagon || !wagon.allocations?.[0]) return null;
const allocation = wagon.allocations[0];
// GROSS: allocated cargo + the tare of the wagon it sits on.
const grossTons = (allocation.allocatedWeightTons ?? 0) + (wagon.tareWeightTons ?? 0);
return (
<Modal opened={opened} onClose={onClose} title="Confirm Booking Removal" centered>
@@ -40,7 +42,7 @@ export const RemoveBookingModal = ({
</Badge>
</Text>
<Text size="sm">
<strong>Weight:</strong> {allocation.allocatedWeightTons?.toFixed(2) || 0} T
<strong>Gross weight:</strong> {grossTons.toFixed(2)} T
</Text>
<Text size="sm">
<strong>Wagon Slot:</strong> #{wagon.sequenceNo}

View File

@@ -93,10 +93,14 @@ export const TrainConsistView = ({
}
};
const weightUsed = wagons.reduce(
(sum, w) => sum + (w.allocations?.[0]?.allocatedWeightTons ?? 0),
// GROSS: cargo on every allocation + the tare of every wagon in the consist.
// maxPullWeightTons is a gross limit, so the numerator must be gross too.
const cargoUsed = wagons.reduce(
(sum, w) => sum + (w.allocations ?? []).reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
0,
);
const tareUsed = wagons.reduce((sum, w) => sum + (w.tareWeightTons ?? 0), 0);
const weightUsed = cargoUsed + tareUsed;
const lengthUsed = wagons.reduce((sum, w) => sum + (w.lengthMeters ?? 0), 0);
return (

View File

@@ -98,7 +98,7 @@ export const TrainStatsBar = ({
<SimpleGrid cols={{ base: 1, xs: 3 }} spacing="lg">
<StatTile
icon={<Weight size={15} />}
label="Weight"
label="Gross weight"
pct={weightPct}
current={weightUsed.toFixed(1)}
max={weightMax?.toFixed(1) ?? "∞"}

View File

@@ -130,7 +130,9 @@ export const UnassignedBookingsPanel = ({
{bookings.map((booking) => {
const isActive = selectedBookingId === booking.id;
const weight = Number(booking.cargoTotalWeightVgm ?? 0);
// GROSS (cargo + wagon tare) so this badge shares the axis every other
// weight on the page uses — cargo-only here read ~25% light.
const weight = Number(booking.grossWeightTons ?? booking.cargoTotalWeightVgm ?? 0);
const fits = booking.canAssign;
const blockReason = booking.blockReason;

View File

@@ -36,8 +36,12 @@ export const WagonCard = ({
const hasAllocations = Boolean(allocation);
const isBulk = (allocation?.loadType ?? "").toUpperCase().includes("BULK");
const weightUsed = allocation?.allocatedWeightTons ?? 0;
const weightMax = wagon.capacityTons ?? 0;
// GROSS on both sides: loaded cargo + wagon tare, against the wagon's max
// gross (rated payload + tare). Keeps the wagon axis identical to the train
// axis in TrainStatsBar.
const tare = wagon.tareWeightTons ?? 0;
const weightUsed = (allocation?.allocatedWeightTons ?? 0) + tare;
const weightMax = (wagon.capacityTons ?? 0) + tare;
const weightPercent = weightMax ? (weightUsed / weightMax) * 100 : 0;
const wagonType = wagon.wagonType?.code || "UNKNOWN";

View File

@@ -54,10 +54,26 @@ export const TRAIN_RUN_FILTER_OPTIONS = Object.entries(TRAIN_RUN_PAIRS).map(
}),
);
/** The import run implied by an export run; empty string when unset/unknown. */
export const importRunFor = (exportRun: unknown): string =>
TRAIN_RUN_PAIRS[String(exportRun ?? "")] ?? "";
/**
* The import run implied by an export run; empty string when unset/unknown.
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
* back to the numeric convention: import = export + 1.
*/
export const importRunFor = (exportRun: unknown): string => {
const run = String(exportRun ?? "");
const paired = TRAIN_RUN_PAIRS[run];
if (paired) return paired;
return /^\d*[13579]$/.test(run) ? String(Number(run) + 1) : "";
};
/** The export run implied by an import run; empty string when unset/unknown. */
export const exportRunFor = (importRun: unknown): string =>
EXPORT_BY_IMPORT[String(importRun ?? "")] ?? "";
/**
* The export run implied by an import run; empty string when unset/unknown.
* Runs outside the hardcoded pairs (admin-added via dropdown settings) fall
* back to the numeric convention: export = import 1.
*/
export const exportRunFor = (importRun: unknown): string => {
const run = String(importRun ?? "");
const paired = EXPORT_BY_IMPORT[run];
if (paired) return paired;
return /^\d*[02468]$/.test(run) && Number(run) > 0 ? String(Number(run) - 1) : "";
};

View File

@@ -19,6 +19,7 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
id: booking.id,
reference: booking.reference,
contractReference: booking.contractReference ?? null,
contractId: booking.contractId ?? null,
approvalSteps: booking.approvalSteps,
customerLabel: booking.isGovernment
? (booking.governmentInstitution ?? "Government")

View File

@@ -0,0 +1,67 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { IMPORT_TRAIN_OPTIONS } from "@/constants/trainRuns";
import { api } from "@/services/api";
/** Dropdown-settings code holding the admin-managed IMPORT run numbers. */
export const IMPORT_TRAIN_NUMBERS_CODE = "import_train_numbers";
export interface ImportTrainNumberOption {
value: string;
label: string;
disabled?: boolean;
}
/**
* Selectable IMPORT run numbers for the Train Builder, sourced from the
* admin-managed `import_train_numbers` dropdown setting (admins add new runs
* from the Dropdown Settings editor). Falls back to the legacy hardcoded run
* list while the setting is missing or has no options.
*
* Numbers already claimed by an existing train are kept in the list but
* disabled and tagged "in use". Pass `currentNumber` when editing a train so
* its own number stays pickable, and so a legacy number that was removed from
* the setting still renders.
*/
export function useImportTrainNumberOptions(currentNumber?: string | null) {
const settingQuery = useQuery(
api.dropdownSettings.getByCode.queryOptions({
input: { code: IMPORT_TRAIN_NUMBERS_CODE },
staleTime: 5 * 60_000,
retry: false,
}),
);
const usedQuery = useQuery(
api.trainBuilder.usedTrainNumbers.queryOptions({ staleTime: 30_000 }),
);
const options = useMemo<ImportTrainNumberOption[]>(() => {
const configured = [...(settingQuery.data?.children ?? [])]
.filter((option) => !option.disabled)
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map((option) => ({
value: option.value,
label: option.label || option.value,
}));
const base = configured.length ? configured : IMPORT_TRAIN_OPTIONS;
const used = new Set(usedQuery.data?.importTrainNumbers ?? []);
if (currentNumber) used.delete(currentNumber);
const items: ImportTrainNumberOption[] = base.map((option) =>
used.has(option.value)
? { ...option, label: `${option.label} — in use`, disabled: true }
: option,
);
if (currentNumber && !items.some((option) => option.value === currentNumber)) {
items.unshift({ value: currentNumber, label: currentNumber });
}
return items;
}, [settingQuery.data, usedQuery.data, currentNumber]);
return {
options,
isLoading: settingQuery.isLoading || usedQuery.isLoading,
};
}

View File

@@ -27,7 +27,8 @@ export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
// staleTime: 30_000,
staleTime:0,
// Data freshness is driven by mutation invalidation (MutationCache above),
// socket pushes, and explicit polling — not by tab focus. Focus refetch
// just re-fires every mounted query each time the window is refocused.

View File

@@ -48,6 +48,7 @@ import {
useBookingList,
useBookingListSummary,
} from "@/hooks/bookings/useBookings";
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
import { api } from "@/services/api";
import type { BookingListFilter } from "@/services/bookings.service";
import type { BookingListRow } from "@/types/booking";
@@ -308,7 +309,17 @@ export default function BookingRequestsPage() {
return (
<div className="py-1">
{ref ? (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
// Fall back to plain text when the id is missing — the reference is
// still worth showing, it just has nowhere to link to.
(row.original.contractId ? (
<ContractReferenceLink
contractId={row.original.contractId}
contractReference={ref}
className="truncate font-mono text-xs text-foreground underline underline-offset-2 hover:text-primary"
/>
) : (
<span className="truncate font-mono text-xs text-foreground">{ref}</span>
))
) : (
<span className="text-xs text-muted-foreground"></span>
)}

View File

@@ -23,6 +23,7 @@ import {
Clock,
PackageCheck,
PackagePlus,
RotateCcw,
ShieldCheck,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -110,6 +111,16 @@ export default function DocumentClearanceDetailPage() {
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
// The completed booking expired unpaid at train dispatch. Its per-booking
// clearance is finished, so GL rebooks it onto a new day — the customer never
// re-requests the shipment or pays the clearance fee again.
const canRebookExpired =
booking?.status === "EXPIRED" &&
Boolean(booking?.contractId) &&
Number(booking?.totalAmount ?? 0) > 0 &&
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const docsPhaseComplete =
clearance?.milestones?.some(
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
@@ -194,6 +205,19 @@ export default function DocumentClearanceDetailPage() {
>
Create booking
</Button>
) : canRebookExpired ? (
<Button
color="edr-green"
radius="md"
leftSection={<RotateCcw size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${booking!.contractId}/bookings/${id}/complete?copyFrom=${id}`,
)
}
>
Rebook shipment
</Button>
) : undefined
}
/>

View File

@@ -452,7 +452,9 @@ export default function ClearanceDocumentsPage() {
data={contractRows}
status={tableStatus}
onRowClick={(row) =>
navigate(`/dashboard/contracts/clearance/${row.id}`)
navigate(
`/dashboard/contracts/clearance-documents/${row.id}`,
)
}
pagination={{
pageIndex: pagination.pageIndex,

View File

@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import { useLocation, useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -52,9 +52,21 @@ import {
export default function ContractClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const { pathname } = useLocation();
const { view, viewer } = useFileViewer();
const { user } = useAuth();
// The same detail page serves two hubs: the GL "Document Clearance" list and
// the Operations "Clearance Documents" list. Point back-navigation at
// whichever hub the user came through.
const fromOpsHub = pathname.startsWith(
"/dashboard/contracts/clearance-documents",
);
const hubHref = fromOpsHub
? "/dashboard/contracts/clearance-documents"
: "/dashboard/contracts/clearance";
const hubLabel = fromOpsHub ? "Clearance Documents" : "Document Clearance";
const { data: contract, refetch: refetchContract } = useContractDetail(id);
const {
data: clearance,
@@ -153,12 +165,9 @@ export default function ContractClearanceDetailPage() {
<PageContainer>
<PageHeader
title="Clearance not found"
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: "Not found" },
]}
/>
@@ -176,12 +185,9 @@ export default function ContractClearanceDetailPage() {
<Stack gap="lg">
<PageHeader
title={reference}
backTo="/dashboard/contracts/clearance"
backTo={hubHref}
breadcrumbs={[
{
label: "Document Clearance",
href: "/dashboard/contracts/clearance",
},
{ label: hubLabel, href: hubHref },
{ label: reference },
]}
meta={

View File

@@ -197,6 +197,29 @@ function DirectionIcon({ direction }: { direction: string }) {
}
function StatusBadge({ row }: { row: ClearanceRow }) {
// Terminal contracts stay listed as history — badge the terminal state
// instead of falling through to "Under review".
if (["EXPIRED", "CANCELLED", "REJECTED"].includes(row.status)) {
return (
<Tooltip
label="This contract is no longer active — kept here for clearance history."
withArrow
>
<Badge
size="sm"
variant="light"
color={row.status === "EXPIRED" ? "orange" : "red"}
radius="sm"
>
{row.status === "EXPIRED"
? "Contract expired"
: row.status === "CANCELLED"
? "Cancelled"
: "Rejected"}
</Badge>
</Tooltip>
);
}
if (row.paymentExpired) {
return (
<Tooltip
@@ -727,8 +750,12 @@ export default function ContractClearanceListPage() {
)
}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh create-booking would spawn a
// new instance and force the customer through clearance + fee
// again.
navigate(
`/dashboard/contracts/${row.contractId}/create-booking?copyFrom=${row.id}`,
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete?copyFrom=${row.id}`,
)
}
onViewContract={(contractId) =>
@@ -813,6 +840,17 @@ const shipmentStatusColor = (s: string) => {
if (s === "AWAITING_DOCUMENTS") return "yellow";
if (s === "DOCUMENTS_UNDER_REVIEW") return "blue";
if (s === "CLEARANCE_READY") return "edr-green";
if (
[
"SELECTED_FOR_BATCH",
"PNR_GENERATED",
"AWAITING_PAYMENT",
"PAYMENT_VERIFICATION_IN_PROGRESS",
].includes(s)
)
return "violet";
if (s === "EXPIRED") return "orange";
if (s === "CANCELLED" || s === "REJECTED") return "red";
return "gray";
};

View File

@@ -4,11 +4,14 @@ import {
Button,
Card,
Group,
MultiSelect,
Select,
Stack,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks";
import {
AlertTriangle,
@@ -17,6 +20,7 @@ import {
CheckCircle2,
Clock,
FileText,
FilterX,
Inbox,
LayoutList,
RefreshCw,
@@ -36,7 +40,10 @@ import {
} from "@/components/contracts/ContractStatusTabs";
import { bookingTable } from "@/components/bookings/booking-ui.styles";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { CONTRACT_LIST_TABS } from "@/features/contracts/contract-status.config";
import {
CONTRACT_LIST_TABS,
CONTRACT_STATUS_STYLES,
} from "@/features/contracts/contract-status.config";
import {
getStaffRowAction,
toContractListRow,
@@ -61,6 +68,63 @@ function getStatusesForTab(tab: ContractStatusTabKey): string | undefined {
return match.statuses.join(",");
}
/** Statuses selectable in the status filter for a given tab ("all" → every tab status). */
function getStatusOptionsForTab(
tab: ContractStatusTabKey,
): { value: string; label: string }[] {
const match = CONTRACT_LIST_TABS.find((t) => t.key === tab);
const statuses = match?.statuses?.length
? match.statuses
: CONTRACT_LIST_TABS.flatMap((t) => t.statuses ?? []);
return statuses.map((s) => ({
value: s,
label: CONTRACT_STATUS_STYLES[s]?.label ?? s,
}));
}
const TRADE_DIRECTION_OPTIONS = [
{ value: "IMPORT", label: "Import" },
{ value: "EXPORT", label: "Export" },
{ value: "DOMESTIC", label: "Domestic" },
];
const FREIGHT_TYPE_OPTIONS = [
{ value: "CONTAINER", label: "Container" },
{ value: "BULK", label: "Bulk" },
];
const CONTRACT_KIND_OPTIONS = [
{ value: "GENERAL", label: "General (recurring)" },
{ value: "ONE_TIME", label: "One-time" },
];
const CURRENCY_OPTIONS = [
{ value: "ETB", label: "ETB" },
{ value: "USD", label: "USD" },
];
/** value = `${sortBy}:${sortOrder}` for the sort Select. */
const SORT_OPTIONS = [
{ value: "createdAt:DESC", label: "Newest first" },
{ value: "createdAt:ASC", label: "Oldest first" },
{ value: "contractValidUntil:ASC", label: "Expiring soonest" },
{ value: "contractValidUntil:DESC", label: "Expiring latest" },
];
/** Local start-of-day → ISO, for inclusive "from" date filters. */
function startOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x.toISOString();
}
/** Local end-of-day → ISO, for inclusive "to" date filters. */
function endOfDayIso(d: Date): string {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x.toISOString();
}
function formatDate(value: string | null | undefined): string {
if (!value) return "—";
const d = new Date(value);
@@ -79,32 +143,86 @@ export default function ContractRequestsPage() {
const [query, setQuery] = useState("");
const [debouncedQuery] = useDebouncedValue(query, 300);
const [activeTab, setActiveTab] = useState<ContractStatusTabKey>("all");
// Filter controls (empty/null = "all").
const [statusFilter, setStatusFilter] = useState<string[]>([]);
const [directionFilter, setDirectionFilter] = useState<string | null>(null);
const [freightTypeFilter, setFreightTypeFilter] = useState<string | null>(
null,
);
const [kindFilter, setKindFilter] = useState<string | null>(null);
const [currencyFilter, setCurrencyFilter] = useState<string | null>(null);
const [createdFrom, setCreatedFrom] = useState<Date | null>(null);
const [createdTo, setCreatedTo] = useState<Date | null>(null);
const [sort, setSort] = useState<string>("createdAt:DESC");
const tabStatuses = getStatusesForTab(activeTab);
const statusOptions = useMemo(
() => getStatusOptionsForTab(activeTab),
[activeTab],
);
const resetPage = useCallback(() => {
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const filter: ContractListFilter = useMemo(
() => ({
const filter: ContractListFilter = useMemo(() => {
const [sortBy, sortOrder] = sort.split(":") as [string, "ASC" | "DESC"];
return {
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
sortBy: "createdAt",
sortOrder: "DESC",
sortBy,
sortOrder,
tab: activeTab,
// Server-side free-text search (contract reference, customer name).
...(debouncedQuery.trim() ? { search: debouncedQuery.trim() } : {}),
...(tabStatuses ? { statuses: tabStatuses } : {}),
}),
[
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
],
);
// Explicit status picks narrow within the tab; otherwise the tab's
// status group applies.
...(statusFilter.length
? { statuses: statusFilter.join(",") }
: tabStatuses
? { statuses: tabStatuses }
: {}),
...(directionFilter ? { tradeDirection: directionFilter } : {}),
...(freightTypeFilter ? { freightType: freightTypeFilter } : {}),
...(kindFilter ? { contractKind: kindFilter } : {}),
...(currencyFilter ? { paymentCurrency: currencyFilter } : {}),
...(createdFrom ? { createdFrom: startOfDayIso(createdFrom) } : {}),
...(createdTo ? { createdTo: endOfDayIso(createdTo) } : {}),
};
}, [
pagination.pageIndex,
pagination.pageSize,
activeTab,
tabStatuses,
debouncedQuery,
statusFilter,
directionFilter,
freightTypeFilter,
kindFilter,
currencyFilter,
createdFrom,
createdTo,
sort,
]);
const activeFilterCount =
(statusFilter.length ? 1 : 0) +
(directionFilter ? 1 : 0) +
(freightTypeFilter ? 1 : 0) +
(kindFilter ? 1 : 0) +
(currencyFilter ? 1 : 0) +
(createdFrom || createdTo ? 1 : 0);
const clearFilters = useCallback(() => {
setStatusFilter([]);
setDirectionFilter(null);
setFreightTypeFilter(null);
setKindFilter(null);
setCurrencyFilter(null);
setCreatedFrom(null);
setCreatedTo(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}, [setPagination, pagination.pageSize]);
const { data, isLoading, isError, refetch, isFetching } =
useContractList(filter);
@@ -341,6 +459,8 @@ export default function ContractRequestsPage() {
active={activeTab}
onChange={(tab) => {
setActiveTab(tab);
// Status picks belong to the previous tab's option set — reset.
setStatusFilter([]);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
counts={tabCounts}
@@ -349,38 +469,159 @@ export default function ContractRequestsPage() {
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Stack gap="sm">
<Group justify="space-between" gap="md" wrap="wrap">
<TextInput
placeholder="Search reference or customer…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => {
setQuery(e.target.value);
resetPage();
}}
rightSection={
query && (
<ActionIcon
size="sm"
color="gray"
radius="md"
variant="transparent"
onClick={() => {
setQuery("");
resetPage();
}}
>
<X size={16} />
</ActionIcon>
)
}
style={{ flex: 1, minWidth: "200px" }}
radius="lg"
/>
<Select
data={SORT_OPTIONS}
value={sort}
onChange={(v) => {
setSort(v ?? "createdAt:DESC");
resetPage();
}}
allowDeselect={false}
radius="lg"
style={{ minWidth: 170 }}
aria-label="Sort contracts"
/>
<Text size="sm" c="dimmed">
{total} record{total !== 1 ? "s" : ""}
</Text>
</Group>
<Group gap="sm" wrap="wrap">
<MultiSelect
placeholder={
statusFilter.length ? undefined : "All statuses"
}
data={statusOptions}
value={statusFilter}
onChange={(v) => {
setStatusFilter(v);
resetPage();
}}
clearable
searchable
radius="lg"
style={{ minWidth: 220 }}
aria-label="Filter by status"
/>
<Select
placeholder="All directions"
data={TRADE_DIRECTION_OPTIONS}
value={directionFilter}
onChange={(v) => {
setDirectionFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 150 }}
aria-label="Filter by trade direction"
/>
<Select
placeholder="All freight types"
data={FREIGHT_TYPE_OPTIONS}
value={freightTypeFilter}
onChange={(v) => {
setFreightTypeFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by freight type"
/>
<Select
placeholder="All kinds"
data={CONTRACT_KIND_OPTIONS}
value={kindFilter}
onChange={(v) => {
setKindFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 160 }}
aria-label="Filter by contract kind"
/>
<Select
placeholder="All currencies"
data={CURRENCY_OPTIONS}
value={currencyFilter}
onChange={(v) => {
setCurrencyFilter(v);
resetPage();
}}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Filter by payment currency"
/>
<DateInput
placeholder="Created from"
value={createdFrom}
onChange={(v) => {
setCreatedFrom(v ? new Date(v) : null);
resetPage();
}}
maxDate={createdTo ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created from"
/>
<DateInput
placeholder="Created to"
value={createdTo}
onChange={(v) => {
setCreatedTo(v ? new Date(v) : null);
resetPage();
}}
minDate={createdFrom ?? undefined}
clearable
radius="lg"
style={{ minWidth: 140 }}
aria-label="Created to"
/>
{activeFilterCount > 0 ? (
<Button
variant="subtle"
color="gray"
radius="lg"
leftSection={<FilterX size={16} />}
onClick={clearFilters}
>
Clear filters ({activeFilterCount})
</Button>
) : null}
</Group>
</Stack>
</Box>
{showEmpty ? (

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useNavigate, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import {
Alert,
Badge,
@@ -18,7 +18,6 @@ import {
AlertCircle,
ClipboardList,
FileText,
PackagePlus,
Upload,
} from "lucide-react";
import type { Freight } from "@edr/types";
@@ -61,9 +60,12 @@ type GlClearanceDetail =
async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
try {
// Probe the contract endpoints first; a booking-id row 404s here by design
// and falls back to the booking lookup below. Suppress the global error
// modal so that expected 404 never surfaces to the user.
const [clearance, contract] = await Promise.all([
contractsService.getClearance(id),
contractsService.getById(id),
contractsService.getClearance(id, { suppressErrorModal: true }),
contractsService.getById(id, { suppressErrorModal: true }),
]);
return {
kind: "contract",
@@ -89,7 +91,6 @@ async function loadGlClearanceDetail(id: string): Promise<GlClearanceDetail> {
/** Djibouti GL clearance detail — RO/DO upload and read-only upstream context. */
export default function GlClearanceDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const { view, viewer } = useFileViewer();
const [uploadKind, setUploadKind] = useState<GlClearanceUploadKind | null>(null);
@@ -197,19 +198,6 @@ export default function GlClearanceDetailPage() {
{hasRo ? "Replace RO" : "Upload RO"}
</Button>
)}
{canCompleteBooking && shipmentBooking ? (
<Button
color="edr-green"
leftSection={<PackagePlus size={16} />}
onClick={() =>
navigate(
`/dashboard/contracts/${shipmentBooking.contractId}/bookings/${id}/complete`,
)
}
>
Create booking
</Button>
) : null}
</Group>
}
/>

View File

@@ -106,6 +106,18 @@ function statusColor(status: string): string {
case "ACTIVE_SHIPMENT_IN_PROGRESS":
case "IN_TRANSIT":
return "teal";
// Payment phase — booking selected / awaiting the customer's payment.
case "SELECTED_FOR_BATCH":
case "PNR_GENERATED":
case "AWAITING_PAYMENT":
case "PAYMENT_VERIFICATION_IN_PROGRESS":
return "violet";
// Terminal rows kept as clearance history.
case "EXPIRED":
return "orange";
case "CANCELLED":
case "REJECTED":
return "red";
default:
return "gray";
}

View File

@@ -18,6 +18,8 @@ import {
CalendarClock,
MapPin,
MoreHorizontal,
Power,
PowerOff,
Replace,
Ruler,
Trash2,
@@ -70,6 +72,7 @@ export default function TrainBuilderDetailPage() {
const [locoModalOpen, setLocoModalOpen] = useState(false);
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const compositionQuery = useQuery(
api.trainBuilder.composition.queryOptions({ input: { id }, enabled: Boolean(id) }),
@@ -81,6 +84,8 @@ export default function TrainBuilderDetailPage() {
);
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
const composition = compositionQuery.data;
const busy =
@@ -172,6 +177,27 @@ export default function TrainBuilderDetailPage() {
>
Change yard
</Menu.Item>
{composition.status === "DEACTIVATED" ? (
<Menu.Item
leftSection={<Power size={15} />}
onClick={() =>
void withToast(async () => {
await activate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} reactivated` });
}, "Could not reactivate train")
}
>
Reactivate train
</Menu.Item>
) : (
<Menu.Item
leftSection={<PowerOff size={15} />}
disabled={composition.activeSchedules.length > 0}
onClick={() => setDeactivateOpen(true)}
>
Deactivate train
</Menu.Item>
)}
<Menu.Item
color="red"
leftSection={<Trash2 size={15} />}
@@ -356,6 +382,39 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={deactivateOpen}
onClose={() => setDeactivateOpen(false)}
title={<Text fw={600}>Deactivate train {composition.code}?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
The train is parked and cannot be picked for new schedules until it is
reactivated. Its locomotives and wagons stay coupled.
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={() => setDeactivateOpen(false)}>
Keep active
</Button>
<Button
color="gray"
loading={deactivate.isPending}
onClick={() =>
void withToast(async () => {
await deactivate.mutateAsync(composition.id);
toast({ title: `Train ${composition.code} deactivated` });
setDeactivateOpen(false);
}, "Could not deactivate train")
}
>
Deactivate
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={disbandOpen}
onClose={() => setDisbandOpen(false)}

View File

@@ -315,6 +315,7 @@ export default function TrainBuilderListPage() {
{ value: "IN_SERVICE", label: "In service" },
{ value: "UNDER_MAINTENANCE", label: "Under maintenance" },
{ value: "OUT_OF_SERVICE", label: "Out of service" },
{ value: "DEACTIVATED", label: "Deactivated" },
]}
w={180}
styles={{ input: { borderColor: "var(--mantine-color-gray-3)" } }}

View File

@@ -66,8 +66,8 @@ import { useToast } from "@/hooks/use-toast";
import type {
BatchBoardBookingDetail,
BatchBoardBookingState,
BatchBoardCounts,
BatchBoardScheduleDetail,
BatchWindowGroup,
BookingAllocationStatus,
} from "@/types/trainScheduling";
@@ -398,7 +398,7 @@ const BookingTable = memo(function BookingTable({
);
});
function WindowCountChips({ counts }: { counts: BatchWindowGroup["counts"] }) {
function WindowCountChips({ counts }: { counts: BatchBoardCounts }) {
const chips: Array<{ value: number; color: string; label: string }> = [
{ value: counts.allocated, color: "edr-green", label: "allocated" },
{ value: counts.selectedForBatch, color: "orange", label: "selected" },
@@ -609,9 +609,7 @@ export default function BatchScheduleDetailPage() {
const hasAssignedWagons = useMemo(
() =>
Boolean(
data?.windows.some((w) =>
w.bookings.some((b) => b.allocationStatus === "ASSIGNED"),
) ||
data?.bookings.some((b) => b.allocationStatus === "ASSIGNED") ||
data?.pendingContract.bookings.some(
(b) => b.allocationStatus === "ASSIGNED",
),
@@ -619,36 +617,33 @@ export default function BatchScheduleDetailPage() {
[data],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const scheduleDetailQuery = useQuery(
api.trainScheduling.scheduleDetail.queryOptions({
input: { id: scheduleId ?? "", freightType: "CONTAINER" },
enabled: Boolean(scheduleId),
// The heavy composition graph is only rendered by the composition tab and
// the overview diagram (which needs assigned wagons) — don't fetch it
// until one of them can actually show something.
enabled:
Boolean(scheduleId) &&
(hasAssignedWagons || activeTab === "composition"),
// Composition data only changes through mutations, which invalidate the
// whole train-scheduling root — no need to refetch on remounts in between.
staleTime: 5 * 60_000,
}),
);
// Every booking on this schedule, flattened across windows + pending-contract,
// de-duplicated (a booking only appears once). Feeds the management table.
// Every booking on this schedule: in-window + pending-contract (the two
// buckets are disjoint). Feeds the management table.
const allBookings = useMemo(() => {
if (!data) return [] as BatchBoardBookingDetail[];
const merged = [
...data.windows.flatMap((w) => w.bookings),
...data.pendingContract.bookings,
];
const byId = new Map<string, BatchBoardBookingDetail>();
for (const b of merged) if (!byId.has(b.id)) byId.set(b.id, b);
return [...byId.values()];
return [...data.bookings, ...data.pendingContract.bookings];
}, [data]);
// All bookings that fall inside the schedule's booking window (every window
// cycle, flattened) — the window is one booking day, so these belong to the
// single window panel above.
const windowBookings = useMemo(
() => (data?.windows ?? []).flatMap((w) => w.bookings),
[data?.windows],
);
// Bookings inside the schedule's booking window — they belong to the single
// window panel above.
const windowBookings = data?.bookings ?? [];
const windowCounts = useMemo(() => {
const counts = {
@@ -684,7 +679,6 @@ export default function BatchScheduleDetailPage() {
[data?.status],
);
const [activeTab, setActiveTab] = useState<string | null>("overview");
const [adjustConsistOpen, setAdjustConsistOpen] = useState(false);
const [selectedBookingId, setSelectedBookingId] = useState<string | null>(
null,

View File

@@ -40,16 +40,29 @@ const isWaiting = (r: IntercityRideAlongRow) =>
const isRiding = (r: IntercityRideAlongRow) => r.status === "IN_TRANSIT";
const isDone = (r: IntercityRideAlongRow) => r.status === "COMPLETED";
/** Yards with no equipment can never load/unload — surface it before the train arrives. */
function FacilityCell({ yard, has }: { yard: string | null; has: boolean | null }) {
/**
* A yard that can't handle THIS booking's cargo can never work it — surface that
* while the train is still coming, not when the load is refused. Containers need
* a facility with a stacker (Indode, Modjo, Dire Dawa); bulk is handled at all of
* them.
*/
function FacilityCell({
yard,
has,
freightType,
}: {
yard: string | null;
has: boolean | null;
freightType: string | null;
}) {
if (!yard) return <Text size="sm"></Text>;
if (has) return <Text size="sm">{yard}</Text>;
return (
<Tooltip
label="This yard has no load/unload facility — cargo cannot be handled here"
label={`${yard} cannot handle ${(freightType ?? "this").toLowerCase()} cargo — no facility here, or no equipment for it`}
withArrow
multiline
w={240}
w={260}
>
<Group gap={4} wrap="nowrap">
<AlertTriangle size={13} color="var(--mantine-color-red-6)" />
@@ -95,7 +108,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
<Table.Td>{r.customer ?? "—"}</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.origin} has={r.originHasFacility} />
<FacilityCell yard={r.origin} has={r.originHasFacility} freightType={r.freightType} />
{atOrigin(r) && isWaiting(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -105,7 +118,7 @@ function Rows({ rows }: { rows: IntercityRideAlongRow[] }) {
</Table.Td>
<Table.Td>
<Group gap={6} wrap="nowrap">
<FacilityCell yard={r.destination} has={r.destinationHasFacility} />
<FacilityCell yard={r.destination} has={r.destinationHasFacility} freightType={r.freightType} />
{atDestination(r) && isRiding(r) && (
<Badge size="xs" color="edr-green" variant="light">
train here
@@ -215,7 +228,7 @@ export default function IntercityPage() {
<Stat icon={<Warehouse size={18} />} label="Completed" value={done.length} />
<Stat
icon={<AlertTriangle size={18} />}
label="No facility"
label="Cannot handle"
value={blocked.length}
color={blocked.length > 0 ? "red" : undefined}
/>
@@ -229,8 +242,9 @@ export default function IntercityPage() {
title={`${blocked.length} booking${blocked.length === 1 ? "" : "s"} cannot be handled`}
mb="md"
>
Their origin or destination yard has no load/unload facility. Mark the yard as
a facility in Configuration Yards, or the cargo can never be worked there.
Their origin or destination yard cannot handle that cargo no facility, or no
equipment for it. Containers need Indode, Modjo or Dire Dawa; bulk is handled at
any facility. Adjust the yard in Configuration Yards.
</Alert>
)}

View File

@@ -193,6 +193,7 @@ import {
type ScheduleConsist,
type TrainComposition,
type UpdateTrainDetailsPayload,
type UsedTrainNumbers,
} from "./trainBuilder.service";
import { trainSchedulingService } from "./trainScheduling.service";
import { wagonTypesService, type WagonType } from "./wagon-types.service";
@@ -1825,6 +1826,14 @@ export const api = {
({ id }) => QUERY_KEYS.TRAIN_BUILDER.composition(id),
),
// Key derives to ["train-builder", "usedTrainNumbers"], so the shared
// TRAIN_BUILDER.ROOT invalidation refreshes it after every build/edit.
usedTrainNumbers: endpoint<void, UsedTrainNumbers>(
"train-builder",
"usedTrainNumbers",
() => trainBuilderService.usedTrainNumbers().then((r) => r.data),
),
build: endpoint<BuildTrainPayload, TrainComposition>(
"train-builder",
"build",
@@ -1902,6 +1911,22 @@ export const api = {
() => TRAIN_BUILDER_INVALIDATIONS,
),
deactivate: endpoint<string, TrainComposition>(
"train-builder",
"deactivate",
(id) => trainBuilderService.deactivate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
activate: endpoint<string, TrainComposition>(
"train-builder",
"activate",
(id) => trainBuilderService.activate(id).then((r) => r.data),
undefined,
() => TRAIN_BUILDER_INVALIDATIONS,
),
disband: endpoint<string, void>(
"train-builder",
"disband",

View File

@@ -16,6 +16,9 @@ export interface ContractListFilter {
tradeDirection?: string;
contractKind?: string;
paymentCurrency?: string;
/** Created-at range (ISO strings, inclusive). */
createdFrom?: string;
createdTo?: string;
/** Server-side free-text search (contract reference, company name). */
search?: string;
page?: number;
@@ -139,6 +142,8 @@ function buildListParams(filter?: ContractListFilter) {
if (filter.tradeDirection) params.tradeDirection = filter.tradeDirection;
if (filter.contractKind) params.contractKind = filter.contractKind;
if (filter.paymentCurrency) params.paymentCurrency = filter.paymentCurrency;
if (filter.createdFrom) params.createdFrom = filter.createdFrom;
if (filter.createdTo) params.createdTo = filter.createdTo;
}
return params;
}
@@ -164,8 +169,11 @@ export const contractsService = {
};
},
getById: async (id: string): Promise<Freight.IContract> => {
const response = await client.get<Freight.IContract>(C.BY_ID(id));
getById: async (
id: string,
opts?: { suppressErrorModal?: boolean },
): Promise<Freight.IContract> => {
const response = await client.get<Freight.IContract>(C.BY_ID(id), opts);
return unwrap(response.data) as Freight.IContract;
},
@@ -258,8 +266,11 @@ export const contractsService = {
};
},
getClearance: async (id: string): Promise<Freight.ContractClearanceView> => {
const response = await client.get(C.CLEARANCE(id));
getClearance: async (
id: string,
opts?: { suppressErrorModal?: boolean },
): Promise<Freight.ContractClearanceView> => {
const response = await client.get(C.CLEARANCE(id), opts);
return unwrap(response.data) as Freight.ContractClearanceView;
},

View File

@@ -9,7 +9,8 @@ export type BuiltTrainStatus =
| "SCHEDULED"
| "IN_SERVICE"
| "UNDER_MAINTENANCE"
| "OUT_OF_SERVICE";
| "OUT_OF_SERVICE"
| "DEACTIVATED";
export interface YardRefLite {
id: string;
@@ -140,6 +141,12 @@ export interface BuildTrainPayload {
notes?: string;
}
/** Run numbers already claimed by existing (non-deleted) trains. */
export interface UsedTrainNumbers {
importTrainNumbers: string[];
exportTrainNumbers: string[];
}
/** Edit a built train's display identity; omitted fields keep their value. */
export interface UpdateTrainDetailsPayload {
/** Empty string clears the name. */
@@ -261,6 +268,8 @@ export const trainBuilderService = {
list: (filters: BuiltTrainListFilters = {}) =>
apiClient.get<BuiltTrainListResponse>(`${BASE}${toQuery(filters)}`),
getComposition: (id: string) => apiClient.get<TrainComposition>(`${BASE}/${id}`),
/** Import/export run numbers already claimed by existing trains. */
usedTrainNumbers: () => apiClient.get<UsedTrainNumbers>(`${BASE}/used-train-numbers`),
build: (payload: BuildTrainPayload) => apiClient.post<TrainComposition>(BASE, payload),
setLocomotives: (id: string, locomotiveIds: string[]) =>
apiClient.put<TrainComposition>(`${BASE}/${id}/locomotives`, { locomotiveIds }),
@@ -279,6 +288,11 @@ export const trainBuilderService = {
apiClient.post<TrainComposition>(`${BASE}/${id}/wagons/${wagonId}/maintenance`),
reorderWagons: (id: string, wagonIds: string[]) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/reorder-wagons`, { wagonIds }),
/** Park the train indefinitely — only allowed with no active schedule. */
deactivate: (id: string) =>
apiClient.post<TrainComposition>(`${BASE}/${id}/deactivate`),
/** Bring a DEACTIVATED train back to AVAILABLE. */
activate: (id: string) => apiClient.post<TrainComposition>(`${BASE}/${id}/activate`),
disband: (id: string) => apiClient.delete<void>(`${BASE}/${id}`),
/** Built trains schedulable on a route (train-scheduling picker). */
availableTrains: (routeId: string) =>

View File

@@ -79,6 +79,8 @@ export interface BookingContainerUnit {
vgmTons: number;
isHazardous?: boolean;
isReefer?: boolean;
/** This container ships back empty after unloading (equipment return). */
isReturn?: boolean;
sortOrder?: number;
}
@@ -231,6 +233,8 @@ export interface BookingListRow {
id: string;
reference: string;
contractReference?: string | null;
/** Needed to link the reference to the contract's detail page. */
contractId?: string | null;
customerLabel: string;
approvalSteps?: BookingApprovalStep[];
status: BookingStatus;

View File

@@ -134,7 +134,11 @@ export interface TrainSchedulePreviewResponse {
deferredBookings?: DeferredBookingRow[];
summary: {
totalBookings: number;
/** Cargo VGM only — display gross instead. */
totalWeightTons: number;
/** GROSS: cargo + the tare of every wagon in the plan. */
grossWeightTons: number;
totalTareTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
@@ -396,23 +400,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
key: string;
label: string;
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
date: string;
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
dateLabel: string;
start: string;
end: string;
counts: {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
};
export interface BatchBoardCounts {
allocated: number;
selectedForBatch: number;
ready: number;
waiting: number;
expired: number;
pendingContract: number;
}
/** A booking bucket on the detail board (in-window vs pending-contract). */
export interface BatchBoardBucket {
counts: BatchBoardCounts;
bookings: BatchBoardBookingDetail[];
}
@@ -439,8 +438,9 @@ export interface BatchBoardScheduleDetail {
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
windows: BatchWindowGroup[];
pendingContract: BatchWindowGroup;
/** Bookings inside the schedule's booking window (fully-executed contracts). */
bookings: BatchBoardBookingDetail[];
pendingContract: BatchBoardBucket;
allocationViolations: string[];
}
@@ -845,6 +845,8 @@ export interface CompositionUnassignedBooking {
freightType: FreightType | null;
priorityScore: number;
cargoTotalWeightVgm: number;
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
grossWeightTons: number;
status: string | null;
schedulingStatus: SchedulingStatus | null;
wagonsRequired: number;

View File

@@ -10,6 +10,7 @@ import {
bookingIsSignable,
} from "@/pages/bookings/contract/ContractSignButton";
import { ApproveDeliveryButton } from "@/pages/bookings/delivery/ApproveDeliveryButton";
import { ContractReferenceLink } from "@/pages/bookings/booking-display";
interface BookingRowProps {
booking: any;
@@ -27,9 +28,15 @@ export const BookingRow = memo(function BookingRow({
const AIcon = cfg.action.icon;
const ap = ACTION_PROPS[cfg.action.kind];
// Payable bookings get an inline "Pay now" that opens the payment modal
// instead of navigating to the detail page.
// instead of navigating to the detail page. A general contract is payable as
// soon as it's FULLY_EXECUTED (signed); a one-time booking only after it's
// SELECTED_FOR_BATCH — same rule as the bookings list's PrimaryAction.
const payableStatus =
booking.bookingType === "GENERAL_CONTRACT"
? "FULLY_EXECUTED"
: "SELECTED_FOR_BATCH";
const canPay =
booking.status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
booking.status === payableStatus && booking.paymentStatus !== "PAID";
// Clearance/operation steps + changes-requested resubmit can be done in place
// via a modal on the row.
const hasInlineAction = bookingHasInlineAction(booking);
@@ -73,6 +80,7 @@ export const BookingRow = memo(function BookingRow({
<Text fz={15} fw={700} c="edr-text" truncate>
{booking.reference}
</Text>
<ContractReferenceLink booking={booking} />
<Text fz={12} c="edr-muted" truncate>
{commodity} · {origin} {dest}
</Text>

View File

@@ -13,7 +13,6 @@ import type { Freight } from "@edr/types";
import { BookingActionModal } from "@/pages/bookings/clearance/BookingActionModal";
import { getBookingNextAction } from "@/pages/bookings/clearance/bookingNextAction";
import { BookingClearanceWorkflowBanner } from "@/pages/bookings/BookingClearanceWorkflowBanner";
import { CardTitle, SectionCard } from "./layout";
@@ -65,8 +64,9 @@ export function ClearanceCard({ booking }: { booking: Freight.IBooking }) {
return (
<SectionCard>
<BookingClearanceWorkflowBanner booking={booking} />
<Group justify="space-between" align="center" mb="md" mt="md">
{/* The "Clearance progress" stepper moved into the unified journey
wizard at the top of the page — this card keeps only the actions. */}
<Group justify="space-between" align="center" mb="md">
<CardTitle>Clearance documents</CardTitle>
{action && (
<Button

View File

@@ -1,9 +1,8 @@
import { useMemo, useState } from "react";
import { Alert, Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Alert, Box, Button, Group, Stack, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
Building2,
Check,
ClipboardList,
Download,
Eye,
@@ -25,7 +24,6 @@ import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { saveBlob } from "@/utils/download";
import { fmtDate } from "../utils";
import { IconSquare } from "./Documents";
import { CardTitle, SectionCard } from "./layout";
@@ -150,113 +148,7 @@ function FileRow({
);
}
// ── customs clearance progress timeline ──────────────────────────────────────
const REGION_LABELS: Record<string, string> = {
CUST: "You",
ET: "GL Ethiopia",
DJ: "GL Djibouti",
OPS: "Operations",
};
function MilestoneTimeline({
milestones,
}: {
milestones: Freight.IClearanceMilestone[];
}) {
const ordered = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
const currentIdx = ordered.findIndex((m) => m.status === "PENDING");
return (
<Stack gap={0}>
{ordered.map((m, idx) => {
const done = m.status === "COMPLETED";
const skipped = m.status === "SKIPPED";
const active = idx === currentIdx;
const last = idx === ordered.length - 1;
return (
<Group key={m.id} gap={12} align="stretch" wrap="nowrap">
{/* rail: circle + connector */}
<Box
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
width: 26,
flexShrink: 0,
}}
>
<Box
style={{
width: 22,
height: 22,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: done
? "#0EA371"
: active
? "#0C1A2B"
: "#EEF2F6",
color: done || active ? "#fff" : "#9AA8B5",
boxShadow: active ? "0 0 0 4px #D9E0E7" : undefined,
fontSize: 10.5,
fontWeight: 700,
}}
>
{done ? <Check size={13} strokeWidth={3} /> : idx + 1}
</Box>
{!last && (
<Box
style={{
width: 2,
flex: 1,
minHeight: 14,
backgroundColor: done ? "#0EA371" : "#E1E7EE",
}}
/>
)}
</Box>
<Box pb={last ? 0 : 14} miw={0} flex={1}>
<Group gap={8} wrap="nowrap" align="center">
<Text
fz="13.5px"
fw={active ? 800 : 700}
c={done || active ? "#10202F" : "#9AA8B5"}
td={skipped ? "line-through" : undefined}
truncate
>
{m.milestoneLabel}
</Text>
{m.ownerRegion && REGION_LABELS[m.ownerRegion] && (
<Badge
size="xs"
variant="light"
color={m.ownerRegion === "CUST" ? "orange" : "gray"}
radius="sm"
tt="none"
>
{REGION_LABELS[m.ownerRegion]}
</Badge>
)}
</Group>
<Text fz="11.5px" c="#9AA8B5" mt={1}>
{skipped
? "Skipped"
: done
? `Completed${m.triggeredAt ? ` · ${fmtDate(m.triggeredAt)}` : ""}`
: active
? "Current step"
: "Upcoming"}
{m.note ? ` · ${m.note}` : ""}
</Text>
</Box>
</Group>
);
})}
</Stack>
);
}
// ── contract & profile document grouping ─────────────────────────────────────
@@ -458,18 +350,8 @@ export function DocumentsTab({ booking }: { booking: Freight.IBooking }) {
</SectionCard>
)}
{/* ── customs progress (Path B phased clearance) ──────────────────── */}
{(clearance?.milestones?.length ?? 0) > 0 && (
<SectionCard>
<CardTitle>Customs clearance progress</CardTitle>
<Text fz="12.5px" c="dimmed" mt={4} mb="md">
Every customs step for this shipment completed steps are ticked,
the highlighted one is where it currently stands.
</Text>
<MilestoneTimeline milestones={clearance!.milestones!} />
</SectionCard>
)}
{/* Customs progress lives in the unified journey wizard at the top of
the page (StatusHero → JourneyWizard) — no duplicate timeline here. */}
{(clearance?.workflowFiles?.length ?? 0) > 0 && (
<SectionCard>
<CardTitle>Customs documents</CardTitle>

View File

@@ -0,0 +1,211 @@
import { Badge, Box, Text } from "@mantine/core";
import { Check } from "lucide-react";
import type { Freight } from "@edr/types";
import {
CONTRACT_PROGRESS_STAGES,
PROGRESS_STAGES,
resolveContractStage,
resolveStage,
} from "../constants";
import { isNegative } from "../utils";
type StepState = "done" | "active" | "idle" | "skipped";
interface JourneyStep {
key: string;
label: string;
state: StepState;
/** Milestone owner region (customs flow only) — rendered as a tiny badge. */
owner?: string;
}
const OWNER_LABELS: Record<string, string> = {
CUST: "You",
ET: "GL Ethiopia",
DJ: "GL Djibouti",
OPS: "Operations",
};
/**
* The single source for the booking-journey wizard steps.
*
* Customs (Path B) bookings: the backend GL milestone catalog IS the
* end-to-end process — documents → review → customs declaration → duty/tax →
* wagon & freight payment → loading → transit → arrival → release. Those rows
* are rendered directly (post-booking milestones appear once GL seeds them),
* framed by the booking-level steps the catalog does not carry: the prepaid
* clearance service fee gate at the start and final delivery at the end.
*
* Non-customs bookings: the existing lifecycle stage sets (direct vs
* contract-drawdown) — no customs steps ever show up.
*/
export function buildJourneySteps(
booking: Freight.IBooking,
milestones: Freight.IClearanceMilestone[],
): JourneyStep[] {
const status = booking.status as string;
const isCustoms = Boolean(booking.customsClearingEnabled);
if (isCustoms && milestones.length > 0) {
const sorted = [...milestones].sort((a, b) => a.sortOrder - b.sortOrder);
const firstPendingId = sorted.find((m) => m.status === "PENDING")?.id;
const feeActive = status === "AWAITING_CLEARANCE_PAYMENT";
const delivered = ["COMPLETED", "DELIVERED"].includes(status);
const steps: JourneyStep[] = [
{ key: "booked", label: "Booking initiated", state: "done" },
{
key: "fee",
label: "Clearance fee paid",
state: feeActive ? "active" : "done",
owner: "CUST",
},
...sorted.map<JourneyStep>((m) => ({
key: m.id,
label: m.milestoneLabel,
owner: m.ownerRegion ?? undefined,
state:
m.status === "COMPLETED"
? "done"
: m.status === "SKIPPED"
? "skipped"
: !feeActive && m.id === firstPendingId
? "active"
: "idle",
})),
{ key: "delivered", label: "Delivered", state: delivered ? "done" : "idle" },
];
// Every known milestone is done but the booking hasn't closed yet — the
// delivery step is what's in progress.
if (!feeActive && !firstPendingId && !delivered) {
steps[steps.length - 1].state = "active";
}
return steps;
}
const contractFlow = Boolean(booking.contractId) && !isNegative(status);
const stages = contractFlow ? CONTRACT_PROGRESS_STAGES : PROGRESS_STAGES;
const current = contractFlow
? resolveContractStage(booking)
: resolveStage(booking);
return stages.map((s, idx) => ({
key: s.label,
label: s.label,
state: idx < current ? "done" : idx === current ? "active" : "idle",
}));
}
/**
* One large wizard for the whole booking journey. Steps flow left→right and
* wrap onto the next row when the process is long (customs imports run ~20
* steps → 23 rows) — no sideways scrolling, everything visible at once.
*/
export function JourneyWizard({
booking,
milestones = [],
}: {
booking: Freight.IBooking;
milestones?: Freight.IClearanceMilestone[];
}) {
const steps = buildJourneySteps(booking, milestones);
const last = steps.length - 1;
return (
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(104px, 1fr))",
rowGap: 22,
}}
>
{steps.map((s, idx) => {
const done = s.state === "done";
const active = s.state === "active";
const skipped = s.state === "skipped";
// A connector segment turns green once the step to its left completed
// (skipped steps pass progress through).
const prev = idx > 0 ? steps[idx - 1] : undefined;
const leftOn =
!!prev && (prev.state === "done" || prev.state === "skipped");
const rightOn = done || skipped;
const ownerLabel = s.owner ? OWNER_LABELS[s.owner] : undefined;
return (
<Box key={s.key} miw={0}>
{/* rail: left connector · circle · right connector */}
<Box style={{ display: "flex", alignItems: "center" }}>
<Box
style={{
height: 3,
flex: 1,
borderRadius: 999,
backgroundColor:
idx === 0 ? "transparent" : leftOn ? "#0EA371" : "#E1E7EE",
}}
/>
<Box
style={{
width: 26,
height: 26,
borderRadius: 999,
display: "flex",
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
fontSize: 11,
fontWeight: 700,
backgroundColor: done
? "#0EA371"
: active
? "#0C1A2B"
: "#EEF2F6",
color: done || active ? "#fff" : "#9AA8B5",
border: done || active ? undefined : "1px solid #E1E7EE",
boxShadow: active ? "0 0 0 4px #D9E0E7" : undefined,
}}
>
{done ? <Check size={14} strokeWidth={3} /> : idx + 1}
</Box>
<Box
style={{
height: 3,
flex: 1,
borderRadius: 999,
backgroundColor:
idx === last ? "transparent" : rightOn ? "#0EA371" : "#E1E7EE",
}}
/>
</Box>
{/* label (+ owner badge) centered under the circle */}
<Box mt={7} px={4} style={{ textAlign: "center" }}>
<Text
fz="11.5px"
fw={active ? 800 : 700}
lh={1.25}
c={done || active ? "#10202F" : "#9AA8B5"}
td={skipped ? "line-through" : undefined}
lineClamp={2}
>
{s.label}
</Text>
{ownerLabel && (
<Badge
size="xs"
variant="light"
color={s.owner === "CUST" ? "orange" : "gray"}
radius="sm"
tt="none"
mt={3}
>
{ownerLabel}
</Badge>
)}
</Box>
</Box>
);
})}
</Box>
);
}

View File

@@ -13,7 +13,10 @@ import type { ReactNode } from "react";
import type { Freight } from "@edr/types";
import { bookingStatusLabel } from "@/pages/bookings/booking-display";
import {
bookingStatusLabel,
ContractReferenceLink,
} from "@/pages/bookings/booking-display";
import { bookingSubtitle, isDraftLike, isNegative } from "../utils";
@@ -50,9 +53,12 @@ export function PageHeader({
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
<Stack gap={8} miw={0}>
<Group gap={12} align="center" wrap="wrap">
<Text fz="26px" fw={800} c="#10202F">
{booking.reference}
</Text>
<Stack gap={2} miw={0}>
<Text fz="26px" fw={800} c="#10202F">
{booking.reference}
</Text>
<ContractReferenceLink booking={booking} />
</Stack>
<span
className="inline-flex items-center gap-[7px] rounded-full px-3 py-1.5 text-xs font-bold"

View File

@@ -1,31 +1,16 @@
import { useMemo, useState } from "react";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Badge,
Box,
Button,
FileButton,
Group,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import { Check, Circle, Clock, Receipt, Upload } from "lucide-react";
import type { Freight } from "@edr/types";
import { Box, Button, FileButton, Group, Text } from "@mantine/core";
import { Receipt, Upload } from "lucide-react";
import { contractsService } from "@/services/contracts.service";
import { SectionCard, CardTitle } from "./layout";
const RISK_COLOR: Record<Freight.CustomsRiskLevel, string> = {
GREEN: "green",
YELLOW: "yellow",
RED: "red",
};
/**
* Read-only shipment tracking for the customer (Path B). Shows the GL milestone
* progression and, when GL has advised duty/tax but the slip is not yet paid,
* surfaces a payment-slip upload — the only customer action in this phase.
* Customs (Path B) duty/tax panel: when GL has advised duty/tax but the slip is
* not yet paid, surfaces the payment-slip upload — the customer's only action
* in this phase. The milestone progression itself is rendered by the unified
* journey wizard at the top of the page.
*/
export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
const qc = useQueryClient();
@@ -44,12 +29,6 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
},
});
const sorted = useMemo(
() => [...milestones].sort((a, b) => a.sortOrder - b.sortOrder),
[milestones],
);
const nextPending = sorted.find((m) => m.status === "PENDING");
const dutyAdvised = milestones.find(
(m) => m.milestoneCode === "DUTY_TAXES_ADVISED",
);
@@ -57,11 +36,14 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
const needsDutySlip =
dutyAdvised?.status === "COMPLETED" && dutyPaid?.status !== "COMPLETED";
if (sorted.length === 0) return null;
// The milestone progression itself lives in the unified journey wizard at
// the top of the page — this card only surfaces the customer's one action
// in the customs phase: uploading the duty/tax payment slip.
if (!needsDutySlip) return null;
return (
<SectionCard>
<CardTitle>Shipment tracking</CardTitle>
<CardTitle>Duty &amp; tax payment</CardTitle>
{needsDutySlip ? (
<Box
@@ -115,63 +97,6 @@ export function ShipmentTrackingCard({ bookingId }: { bookingId: string }) {
</Group>
</Box>
) : null}
<Stack gap={0} mt="md">
{sorted.map((m, index) => {
const isLast = index === sorted.length - 1;
const isNext = nextPending?.id === m.id;
const Icon =
m.status === "COMPLETED" ? Check : isNext ? Clock : Circle;
const risk =
m.milestoneCode === "RISK_ASSIGNED"
? m.metadata?.riskLevel
: undefined;
return (
<Group key={m.id} gap="sm" wrap="nowrap" align="flex-start">
<Stack gap={0} align="center" style={{ flexShrink: 0 }}>
<ThemeIcon
variant={m.status === "COMPLETED" ? "filled" : "light"}
color={isNext ? "edr-green" : "gray"}
radius="xl"
size={26}
>
<Icon size={13} strokeWidth={2.2} />
</ThemeIcon>
{!isLast && (
<Box
style={{
width: 2,
flex: 1,
minHeight: 22,
background:
m.status === "COMPLETED"
? "var(--mantine-color-edr-green-4)"
: "var(--mantine-color-gray-2)",
}}
/>
)}
</Stack>
<Box pb={isLast ? 0 : "sm"} style={{ flex: 1, minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Text
fz="sm"
fw={m.status === "COMPLETED" ? 600 : 500}
c={m.status === "COMPLETED" ? undefined : "dimmed"}
>
{m.milestoneLabel}
</Text>
{risk ? (
<Badge size="xs" color={RISK_COLOR[risk]} variant="filled">
{risk}
</Badge>
) : null}
</Group>
</Box>
</Group>
);
})}
</Stack>
</SectionCard>
);
}

View File

@@ -1,18 +1,20 @@
import { Box, Group, Text } from "@mantine/core";
import { Check, MoveRight } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { MoveRight } from "lucide-react";
import type { Freight } from "@edr/types";
import { contractsService } from "@/services/contracts.service";
import {
ARRIVAL_STAGE,
CONTRACT_ARRIVAL_STAGE,
CONTRACT_PROGRESS_STAGES,
PROGRESS_STAGES,
STATUS_MAP,
resolveContractStage,
resolveStage,
} from "../constants";
import { fmtDate, isDraftLike, isNegative, yardLabel } from "../utils";
import { JourneyWizard } from "./JourneyWizard";
import { SectionCard } from "./layout";
/** Origin → destination strip rendered above the progress tracker. */
@@ -73,13 +75,17 @@ export function StatusHero({
children?: React.ReactNode;
}) {
const status = booking.status;
// Customs (Path B) bookings render the GL milestone rows inside the unified
// wizard. Same query key the duty-slip panel uses, so the cache is shared.
const { data: milestones = [] } = useQuery({
queryKey: ["booking-milestones", booking.id],
queryFn: () => contractsService.getBookingMilestones(booking.id),
enabled: Boolean(booking.id) && Boolean(booking.customsClearingEnabled),
});
// Contract-drawdown bookings (initiated under a contract) follow a dedicated
// wizard — initiated → submitted → accepted → payment → … — instead of the
// direct booking's Request/Approval/Contract stages.
const isContractDrawdown = Boolean(booking.contractId) && !isNegative(status);
const stages = isContractDrawdown
? CONTRACT_PROGRESS_STAGES
: PROGRESS_STAGES;
const arrivalStage = isContractDrawdown
? CONTRACT_ARRIVAL_STAGE
: ARRIVAL_STAGE;
@@ -157,128 +163,8 @@ export function StatusHero({
{!negative && <RouteStrip booking={booking} />}
{children ?? (
<ProgressTracker
current={stage}
stages={stages}
tone={draft ? "ink" : "green"}
negative={negative}
/>
<JourneyWizard booking={booking} milestones={milestones} />
)}
</SectionCard>
);
}
function ProgressTracker({
current,
stages = PROGRESS_STAGES,
tone = "green",
}: {
current: number;
/** Which stage set to render — direct or contract-drawdown. */
stages?: typeof PROGRESS_STAGES;
tone?: "green" | "ink";
negative?: boolean;
}) {
const last = stages.length - 1;
const activeFill = tone === "ink" ? "#0C1A2B" : "#0EA371";
const activeRing = tone === "ink" ? "#D9E0E7" : "#BFE8D4";
return (
/* Scrollable on mobile so the stages never overflow */
<Box
className="overflow-x-auto pt-2"
style={
{
scrollbarWidth: "none",
WebkitOverflowScrolling: "touch",
} as React.CSSProperties
}
>
{/* ~84px per stage keeps 2-word labels readable; the box scrolls on mobile. */}
<div
className="flex items-start"
style={{ minWidth: Math.max(640, stages.length * 84) }}
>
{stages.map((stage, idx) => {
const state =
idx < current ? "done" : idx === current ? "active" : "idle";
const Icon = stage.icon;
const reachedLeft = current >= idx && current >= 0;
const reachedRight = current > idx && current >= 0;
const circleBg =
state === "idle"
? "#EEF2F6"
: state === "active"
? activeFill
: "#0EA371";
const circleBorder =
state === "idle" ? "1px solid #E1E7EE" : undefined;
const circleShadow =
state === "active" ? `0 0 0 4px ${activeRing}` : undefined;
return (
<div
key={stage.label}
className="flex flex-1 flex-col items-center"
>
<div className="flex w-full items-center">
{/* left connector */}
<div
className="flex-1 rounded-full"
style={{
height: 3,
background:
idx === 0
? "transparent"
: reachedLeft
? "#0EA371"
: "#E1E7EE",
}}
/>
{/* stage circle */}
<div
className="flex items-center justify-center mb-2 rounded-full shrink-0"
style={{
width: 32,
height: 32,
backgroundColor: circleBg,
border: circleBorder,
boxShadow: circleShadow,
}}
>
{state === "done" ? (
<Check size={16} color="#fff" />
) : state === "active" ? (
<Icon size={16} color="#fff" />
) : null}
</div>
{/* right connector */}
<div
className="flex-1 rounded-full"
style={{
height: 3,
background:
idx === last
? "transparent"
: reachedRight
? "#0EA371"
: "#E1E7EE",
}}
/>
</div>
<Text
fz="14px"
fw={700}
ta="center"
c={state === "idle" ? "#9AA8B5" : "#10202F"}
>
{stage.label}
</Text>
</div>
);
})}
</div>
</Box>
);
}

View File

@@ -37,6 +37,11 @@ import { ShipmentTrackingModal } from "./tracking/ShipmentTrackingModal";
import { PayNowButton } from "./payments/PayNowButton";
import { BookingActionButton } from "./clearance/BookingActionButton";
import { bookingHasInlineAction } from "./clearance/bookingNextAction";
import {
ContractSignButton,
bookingIsSignable,
} from "./contract/ContractSignButton";
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
import {
BookingStatusBadge as StatusBadge,
BookingTypeBadge,
@@ -54,6 +59,7 @@ import {
type ColumnDef,
usePagination,
} from "@edr/ui-common";
import "./bookings-table.css";
// Bookings that have left (or are leaving) the yard can be tracked live.
const TRACKABLE_STATUSES = new Set([
@@ -200,6 +206,14 @@ function PrimaryAction({
if (status === payableStatus && booking.paymentStatus !== "PAID") {
return <PayNowButton booking={booking} />;
}
// Contract ready for the customer's signature → full-page contract viewer.
if (bookingIsSignable(booking)) {
return <ContractSignButton booking={booking} size="xs" />;
}
// Delivered cargo with a handover awaiting the customer's signature.
if (booking.handoverAwaitingSignature) {
return <ApproveDeliveryButton bookingId={id} size="xs" stopPropagation />;
}
return (
<Button
size="xs"
@@ -886,7 +900,7 @@ export default function BookingsListPage() {
manualPagination: true,
pageCount,
}}
containerClassName="border-0 shadow-none rounded-none"
containerClassName="edr-bookings-table border-0 shadow-none rounded-none"
footer={DataTableFooter}
/>
)}

View File

@@ -1,4 +1,5 @@
import { Badge, Box, Group, Text } from "@mantine/core";
import { Link } from "react-router-dom";
import type { Freight } from "@edr/types";
import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
@@ -9,6 +10,40 @@ import { STATUS_CONFIG } from "@/pages/MyPortalPage/constants";
* and detail page render type/freight/mode/payment consistently.
*/
/**
* The parent contract's reference, rendered small under a booking reference and
* linking to that contract's detail page.
*
* Renders nothing when either field is missing: `contractId` is nullable on the
* booking, and only the bookings list/detail endpoints join `contractReference`
* — other endpoints (warehouse, fleet, payments) return booking rows without it,
* and a link with no id would be a dead one.
*
* `stopPropagation` matters: booking rows are click-to-navigate, so without it a
* click here would race the row handler and land on the booking instead.
*/
export function ContractReferenceLink({
booking,
}: {
booking: Pick<Freight.IBooking, "contractId" | "contractReference">;
}) {
if (!booking.contractId || !booking.contractReference) return null;
return (
<Text
component={Link}
to={`/contracts/${booking.contractId}`}
onClick={(e) => e.stopPropagation()}
fz={11}
c="edr-muted"
truncate
style={{ display: "block", textDecoration: "underline" }}
>
{booking.contractReference}
</Text>
);
}
/** Title-case an unmapped enum as a readable fallback ("FOO_BAR" → "Foo Bar"). */
export function titleCaseStatus(status: string): string {
return status

View File

@@ -0,0 +1,84 @@
/*
* Scoped to .edr-bookings-table — the DataTable container div on the bookings
* list only; no other DataTable is affected. Mirrors contracts-table.css:
* content-sized columns with a 40px floor, horizontal scroll when the table
* outgrows the card, and a sticky shadowed action column.
*/
.edr-bookings-table {
overflow-x: auto;
}
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the container scrolls instead.
* min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-bookings-table table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-bookings-table th,
.edr-bookings-table td {
min-width: 40px;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label. Let badges size
* to their text so the column grows to fit them.
*/
.edr-bookings-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (error / empty state) span every column via colspan — leave
* their wrapping alone.
*/
.edr-bookings-table th,
.edr-bookings-table td:not([colspan]) {
white-space: nowrap;
}
/* Sticky header row. */
.edr-bookings-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column, shrunk to its content. The width overrides the inline
* width DataTable stamps from tanstack's default column size — hence
* !important. `:not([colspan])` keeps the full-width error/empty rows out.
*/
.edr-bookings-table th:last-child,
.edr-bookings-table td:last-child:not([colspan]) {
width: 1% !important;
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through.
*/
.edr-bookings-table td:last-child:not([colspan]) {
/* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2;
}
/* Row hover uses the tailwind `hover:bg-accent` class on the <tr>. */
.edr-bookings-table tbody tr:hover td:last-child:not([colspan]) {
background: var(--accent, #f4fbf8);
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-bookings-table th:last-child {
background: #f4f7fa;
z-index: 3;
}

View File

@@ -409,7 +409,11 @@ function LocationPickerInline({
const geocoder = useGeocoder();
const places = usePlacesSearch();
const placesLib = useMapsLibrary("places");
const [query, setQuery] = useState("");
// `null` means "not editing" (show the saved address); any string — including
// "" after the user clears the field — is live edit state. A plain `query ||
// value.address` fallback would snap the saved address back the moment the
// user cleared the input, making it impossible to retype the location.
const [query, setQuery] = useState<string | null>(null);
const [results, setResults] = useState<PlacePrediction[]>([]);
const [searching, setSearching] = useState(false);
const [resolving, setResolving] = useState(false);
@@ -430,7 +434,7 @@ function LocationPickerInline({
// than one per keystroke. While it runs, the input shows a spinner; the
// dropdown itself only appears once there are predictions to show.
useEffect(() => {
const q = query.trim();
const q = (query ?? "").trim();
if (q.length < MIN_QUERY_LEN) {
setResults([]);
setSearching(false);
@@ -469,7 +473,7 @@ function LocationPickerInline({
async (prediction: PlacePrediction) => {
// Clear the query/results immediately so the pending debounce can't fire
// a search for the picked address and pop the dropdown back open.
setQuery("");
setQuery(null);
setResults([]);
// Predictions carry no coordinates — resolve them now via Place Details.
if (!places) return;
@@ -498,6 +502,9 @@ function LocationPickerInline({
const handlePin = useCallback(
async (lat: number, lng: number) => {
// Show the pin immediately; fill the address once reverse geocoding lands.
// Leave edit mode so the input reflects the reverse-geocoded address
// instead of whatever half-typed query the user abandoned for the map.
setQuery(null);
onChange({ address: value.address, lat, lng });
if (!geocoder) return;
// Mark any in-flight reverse lookup stale — only the latest pin counts.
@@ -525,7 +532,7 @@ function LocationPickerInline({
[handlePin],
);
const inputValue = query || value.address;
const inputValue = query ?? value.address;
const center = hasPin
? { lat: value.lat as number, lng: value.lng as number }
: DEFAULT_CENTER;

View File

@@ -141,9 +141,13 @@ const BUSINESS_LICENSE_DOC_CODES = new Set([
// documents" rather than the clearance set.
const PROFILE_DOC_CODES = new Set([
"tin_certificate",
"tin",
"national_id",
"national_id_passport",
"passport",
// The seeded company-onboarding setting stores the national ID field with the
// bare code "id" — without this the doc lands in the clearance catch-all.
"id",
]);
interface DocGroup {
@@ -164,23 +168,44 @@ interface DocGroup {
*/
function groupContractDocuments(
files: ContractFile[],
{ includeClearance = true }: { includeClearance?: boolean } = {},
{
includeClearance = true,
clearanceKeys,
}: { includeClearance?: boolean; clearanceKeys?: Set<string> } = {},
): DocGroup[] {
const businessLicense: ContractFile[] = [];
const profile: ContractFile[] = [];
const clearance: ContractFile[] = [];
const other: ContractFile[] = [];
// A file belongs to the clearance set when its code matches a clearance
// upload field (multi-file uploads append `_<n>`), an ad-hoc `custom_*` doc,
// or a known GL workflow artifact. Without a key list (clearance view not
// loaded for this status) everything unclassified stays under clearance —
// the pre-existing catch-all behaviour.
const isClearanceCode = (code: string): boolean => {
if (code.startsWith("custom_")) return true;
if (clearanceWorkflowFileLabel(code)) return true;
if (!clearanceKeys || clearanceKeys.size === 0) return true;
return (
clearanceKeys.has(code) || clearanceKeys.has(code.replace(/_\d+$/, ""))
);
};
for (const f of files) {
// The generated contract PDF lives in the contract list / home rows, not
// here. Signature images are baked into that PDF — skip both.
if (f.code === "contract" || f.code.startsWith("signature_")) continue;
else if (BUSINESS_LICENSE_DOC_CODES.has(f.code)) businessLicense.push(f);
else if (PROFILE_DOC_CODES.has(f.code)) profile.push(f);
else if (includeClearance) clearance.push(f);
else if (includeClearance && isClearanceCode(f.code)) clearance.push(f);
else other.push(f);
}
return [
{ key: "clearance", title: "Clearance documents", files: clearance },
{ key: "profile", title: "Company profile", files: profile },
{ key: "businessLicense", title: "Business license", files: businessLicense },
{ key: "profile", title: "Profile documents", files: profile },
{ key: "clearance", title: "Clearance documents", files: clearance },
{ key: "other", title: "Other documents", files: other },
].filter((g) => g.files.length > 0);
}
@@ -346,8 +371,13 @@ export default function ContractDetailPage() {
const files = contract.files ?? [];
// GENERAL contracts clear per booking — clearance documents live on each
// booking's detail page, so this tab keeps only profile/licence documents.
// Clearance upload field keys (when the clearance view is loaded) let the
// grouping tell real clearance docs apart from other attachments.
const docGroups = groupContractDocuments(files, {
includeClearance: contract.contractKind !== "GENERAL",
clearanceKeys: new Set(
(clearanceView?.documents ?? []).map((d) => d.fileKey),
),
});
const docCount = docGroups.reduce((sum, g) => sum + g.files.length, 0);
// The generated contract PDF — surfaced via a dedicated "View contract" button
@@ -1190,21 +1220,13 @@ export default function ContractDetailPage() {
/>
) : null}
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group justify="space-between" align="center" mb="md">
<SectionLabel>Contract documents</SectionLabel>
{contract.contractGeneratedAt && (
<Badge size="sm" variant="light" color="edr-green" radius="sm">
Generated
</Badge>
)}
</Group>
{docGroups.length === 0 ? (
{docGroups.length === 0 ? (
<Card
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Stack align="center" gap={10} py={48}>
<Box
style={{
@@ -1228,55 +1250,56 @@ export default function ContractDetailPage() {
: "The signed contract and any uploaded clearance documents will appear here."}
</Text>
</Stack>
) : (
<Stack gap="xl">
{docGroups.map((group) => {
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
return (
<Stack key={group.key} gap={10}>
<Group gap={10} align="center">
<Box
style={{
width: 28,
height: 28,
borderRadius: 8,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `${accent}14`,
color: accent,
}}
>
<Icon size={15} />
</Box>
<Text fz={13} fw={700} style={{ color: INK }}>
{group.title}
</Text>
<Badge
size="sm"
variant="light"
color="gray"
radius="sm"
>
{group.files.length}
</Badge>
</Group>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow
key={file.id}
file={file}
onView={view}
/>
))}
</Stack>
</Stack>
);
})}
</Stack>
)}
</Card>
</Card>
) : (
// One card per section — profile, business licence, clearance,
// other — instead of a single flat list.
docGroups.map((group) => {
const accent = DOC_GROUP_ACCENT[group.key] ?? GREEN;
const Icon = DOC_GROUP_ICON[group.key] ?? FileText;
return (
<Card
key={group.key}
withBorder
radius="lg"
p="lg"
style={{ borderColor: BORDER, boxShadow: CARD_SHADOW }}
>
<Group gap={12} align="center" mb={4}>
<Box
style={{
width: 34,
height: 34,
borderRadius: 10,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: `${accent}14`,
color: accent,
flexShrink: 0,
}}
>
<Icon size={17} />
</Box>
<Text fz={14.5} fw={700} style={{ color: INK }}>
{group.title}
</Text>
<Badge size="sm" variant="light" color="gray" radius="sm">
{group.files.length}
</Badge>
</Group>
<Text fz={12.5} c="dimmed" mb="md" ml={46}>
{DOC_GROUP_DESC[group.key] ?? ""}
</Text>
<Stack gap={10}>
{group.files.map((file) => (
<DocFileRow key={file.id} file={file} onView={view} />
))}
</Stack>
</Card>
);
})
)}
</Stack>
</Tabs.Panel>
@@ -1579,16 +1602,26 @@ const KEY_FACT_ACCENT: Record<string, string> = {
orange: "#C77F09",
};
// Per-section accent + icon for the Documents tab groups.
// Per-section accent + icon + description for the Documents tab groups.
const DOC_GROUP_ACCENT: Record<string, string> = {
clearance: "#C77F09",
businessLicense: "#0A6F4D",
profile: "#2B6CB0",
other: "#64748B",
};
const DOC_GROUP_ICON: Record<string, LucideIcon> = {
clearance: Upload,
businessLicense: FileBadge,
profile: FileText,
other: FileText,
};
const DOC_GROUP_DESC: Record<string, string> = {
profile:
"Identity and onboarding documents attached from your company profile.",
businessLicense: "Business and trade licences on file for this company.",
clearance:
"Documents uploaded for the customs clearance review of this contract.",
other: "Additional files attached to this contract.",
};
/**

View File

@@ -50,6 +50,7 @@ import {
StatCard,
} from "./contract-ui";
import { ContractStepBanner } from "./ContractStepBanner";
import "./contracts-table.css";
function primaryRoute(contract: Freight.IContract) {
const route = contract.routes?.[0];
@@ -367,11 +368,15 @@ export default function ContractsList() {
>
<Box style={{ overflowX: "auto" }}>
<Table
className="edr-contracts-table"
verticalSpacing={14}
horizontalSpacing={20}
horizontalSpacing={10}
highlightOnHover
highlightOnHoverColor="#F4FBF8"
styles={{
// Sticky positioning, z-index and wrapping live in
// contracts-table.css — Mantine's `styles` prop emits inline
// styles, which would outrank the sticky action column there.
th: {
fontSize: 11,
fontWeight: 700,
@@ -380,10 +385,6 @@ export default function ContractsList() {
color: MUTED,
background: "#F8FAFC",
borderBottom: `1px solid ${BORDER}`,
whiteSpace: "nowrap",
position: "sticky",
top: 0,
zIndex: 1,
},
tr: {
transition: "background-color 120ms ease",
@@ -402,6 +403,7 @@ export default function ContractsList() {
<Table.Th>Cargo</Table.Th>
<Table.Th>Route</Table.Th>
<Table.Th>Trade</Table.Th>
<Table.Th>Customs</Table.Th>
<Table.Th>Currency</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th>Valid Until</Table.Th>
@@ -412,7 +414,7 @@ export default function ContractsList() {
<Table.Tbody>
{isLoading && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Loader color="edr-green" size="sm" />
</Center>
@@ -422,7 +424,7 @@ export default function ContractsList() {
{!isLoading && isError && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Center py={48}>
<Text fz={13} c="red">
Failed to load contracts. Please try again.
@@ -434,7 +436,7 @@ export default function ContractsList() {
{!isLoading && !isError && rows.length === 0 && (
<Table.Tr>
<Table.Td colSpan={11}>
<Table.Td colSpan={12}>
<Stack align="center" gap={8} py={48}>
<Inbox
size={26}
@@ -463,6 +465,9 @@ export default function ContractsList() {
return (
<Fragment key={c.id}>
<Table.Tr
// Read by contracts-table.css to keep the sticky
// action cell's opaque background in step with the row.
data-expanded={isOpen ? "true" : undefined}
style={{
cursor: "pointer",
background: isOpen ? "#F4FBF8" : undefined,
@@ -556,6 +561,15 @@ export default function ContractsList() {
{tradeLabel}
</Text>
</Table.Td>
<Table.Td>
<Badge
variant="light"
color={c.customsClearingEnabled ? "edr-green" : "gray"}
radius="sm"
>
{c.customsClearingEnabled ? "With customs" : "Without"}
</Badge>
</Table.Td>
<Table.Td>
<Text fz={13} style={{ color: INK }}>
{c.paymentCurrency ?? "—"}
@@ -608,7 +622,7 @@ export default function ContractsList() {
{isOpen && (
<Table.Tr style={{ background: "#F4FBF8" }}>
<Table.Td
colSpan={11}
colSpan={12}
style={{ padding: "6px 20px 18px" }}
>
<ContractStepBanner contract={c} />

View File

@@ -39,11 +39,9 @@ import {
import useAuth from "@/hooks/useAuth";
import {
CONTRACT_STEPS,
EDIT_CONTRACT_STEPS,
ContractFormInputValues,
contractFormSchema,
contractStepFields,
editContractStepFields,
initialContractFormValues,
OPERATION_TYPES,
type ContractFormValues,
@@ -55,12 +53,6 @@ import {
operationToTradeDirection,
} from "./new-contract-form/helpers";
import { contractToFormValues } from "./new-contract-form/contractToForm";
import {
ContractDocsEditor,
documentSettingCode,
missingRequiredDocKeys,
useCompanyDocuments,
} from "./new-contract-form/ContractDocsEditor";
import { PROFILE_TYPE_LABELS } from "@/constants/profileMode";
import type { ProfileTypeValue } from "@/services/companies.service";
import { StepIndicator } from "./new-contract-form/StepIndicator";
@@ -84,8 +76,8 @@ type PriceModalMode = "submit" | "draft";
* The contract wizard, used both to create a new contract and — in `edit` mode —
* to continue an unsubmitted DRAFT or edit & resubmit a contract staff returned
* with CHANGES_REQUESTED. Edit mode hydrates the form from the saved contract,
* lets the customer change any term and replace documents, then runs the same
* update → price → submit flow.
* lets the customer change any term, then runs the same update → price →
* submit flow. Same 3 steps as create — there is no separate documents step.
*/
export default function NewContractPage({
mode = "create",
@@ -105,22 +97,6 @@ export default function NewContractPage({
...api.contracts.get.queryOptions({ input: { id: editId ?? "" } }),
enabled: isEdit,
});
// Onboarding document requirements — used in edit mode to block resubmit until
// every required document is on file (existing or freshly attached).
const editDocSettingQuery = useQuery({
...api.fileUploadSettings.getByCode.queryOptions({
input: {
code: documentSettingCode(
auth.company?.company?.nationality as string | null | undefined,
),
},
}),
enabled: isEdit,
});
// Profile documents (TIN, licenses, IDs) satisfy requirements too — the API
// carries them onto the contract on save.
const companyDocs = useCompanyDocuments();
// Contract creation is gated on profile approval, same as bookings.
if (!auth.isPending && auth.company && !auth.canBook) {
return <Navigate to="/contracts" replace />;
@@ -167,12 +143,6 @@ export default function NewContractPage({
const [priceContractId, setPriceContractId] = useState<string | null>(
isEdit ? (editId ?? null) : null,
);
// Documents freshly attached on the review step (edit mode only). Merged into
// the form's `documents` map before the contract is updated.
const [editDocuments, setEditDocuments] = useState<
Record<string, File | File[] | null>
>({});
const [showDocErrors, setShowDocErrors] = useState(false);
const [priceModalMode, setPriceModalMode] = useState<PriceModalMode | null>(
null,
);
@@ -311,10 +281,9 @@ export default function NewContractPage({
const destinationYard = form.watch("destinationYard");
const operationType = form.watch("operationType");
const visibleSteps = useMemo(
() => (isEdit ? EDIT_CONTRACT_STEPS : CONTRACT_STEPS),
[isEdit],
);
// Create and edit share the same 3 steps — documents come along automatically
// (company profile docs are carried onto the contract by the API on save).
const visibleSteps = CONTRACT_STEPS;
const visibleStepIds = useMemo<number[]>(
() => visibleSteps.map((s) => s.id),
[visibleSteps],
@@ -519,25 +488,11 @@ export default function NewContractPage({
}, [auth.company, auth.activeCompanyProfileId]);
async function handleContinue() {
const stepFields = isEdit ? editContractStepFields : contractStepFields;
const fields = stepFields[step];
const fields = contractStepFields[step];
if (fields.length > 0) {
const valid = await form.trigger(fields, { shouldFocus: true });
if (!valid) return;
}
if (isEdit && step === 2 && editContract) {
const missing = missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
return;
}
setShowDocErrors(false);
}
goToStep(1);
}
@@ -565,6 +520,8 @@ export default function NewContractPage({
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
// Required cargo description — what the containers carry.
cargoFreeText: data.cargoFreeText.trim() || undefined,
}))
: [
{
@@ -649,26 +606,6 @@ export default function NewContractPage({
const handleSubmitContract = form.handleSubmit((data) => {
try {
// Edit mode: all required documents must be on file (already uploaded or
// freshly attached) before resubmitting, and freshly attached files are
// merged into the form's documents map so the update sends them.
if (isEdit && editContract) {
const missing = missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
);
if (missing.length > 0) {
setShowDocErrors(true);
return;
}
setShowDocErrors(false);
form.setValue("documents", {
...(form.getValues("documents") ?? {}),
...editDocuments,
});
}
const apiPayload = buildApiPayload(data);
persistAndPriceMutation.mutate({
payload: apiPayload,
@@ -724,8 +661,8 @@ export default function NewContractPage({
<Text size="sm" c="edr-muted" mt={4}>
{isEdit
? editContract?.status === "CHANGES_REQUESTED"
? "Update your contract details and documents, then resubmit it for EDR staff review."
: "Update your draft contract details and documents, then submit it for EDR staff review."
? "Update your contract details, then resubmit it for EDR staff review."
: "Update your draft contract details, then submit it for EDR staff review."
: "Define your freight contract — scope, routes, and unit rates. Book shipments against it after signing."}
</Text>
</Box>
@@ -769,12 +706,12 @@ export default function NewContractPage({
{editContract.latestChangeRequestNote}
</Text>
<Text size="sm" c="dimmed" mt={2}>
Update the details or documents below, then resubmit the
contract for review.
Update the details below, then resubmit the contract for
review.
</Text>
</Stack>
) : (
"Update any contract detail or document that needs to change, then resubmit the contract for review."
"Update any contract detail that needs to change, then resubmit the contract for review."
)}
</Alert>
)}
@@ -843,35 +780,8 @@ export default function NewContractPage({
</StepCard>
)}
{/* Step 2 (edit) — Documents. */}
{step === 2 && isEdit && editContract && (
<StepCard>
<StepHeader
title="Contract Documents"
description="Upload or replace the documents required for this contract before resubmitting."
/>
<ContractDocsEditor
contract={editContract}
value={editDocuments}
onChange={setEditDocuments}
errors={
showDocErrors
? Object.fromEntries(
missingRequiredDocKeys(
editDocSettingQuery.data,
editContract,
editDocuments,
companyDocs,
).map((k) => [k, "Required"]),
)
: {}
}
/>
</StepCard>
)}
{/* Step 2 (create) / Step 3 (edit) — Review & Submit. */}
{((step === 2 && !isEdit) || (step === 3 && isEdit)) && (
{/* Step 2 — Review & Submit. */}
{step === 2 && (
<Step8Review
form={form}
setStep={setStep}

View File

@@ -59,8 +59,6 @@ import {
StepCard,
StepHeader,
StepLabel,
ToggleRow,
UnitCountToggles,
fieldStyles,
} from "./new-contract-form/shared";
import { formatRateUnit } from "./new-contract-form/unit-rates";
@@ -362,6 +360,11 @@ function NewShipmentBookingForm({
containerNumber: u.containerNumber,
sealNumber: u.sealNumber || undefined,
vgmTons: Number(u.vgmTons),
// Per-container handling — the server rolls these up into the
// line counts and bills each surcharge on the ticked containers.
isHazardous: Boolean(u.isHazardous),
isReefer: Boolean(u.isReefer),
...(withReturnService ? { isReturn: Boolean(u.isReturn) } : {}),
})),
})),
}
@@ -1133,7 +1136,7 @@ function CargoStep({
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
units: [emptyUnit()],
})),
{ shouldValidate: false },
);
@@ -1177,7 +1180,7 @@ function CargoStep({
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
units: [emptyUnit()],
}
);
}
@@ -1187,10 +1190,15 @@ function CargoStep({
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
reeferQuantity: String(imported.filter((r) => r.reefer).length),
returnQuantity: String(imported.filter((r) => r.withReturn).length),
// The spreadsheet already marks handling per row — carry it onto the
// container it belongs to rather than collapsing it to a line count.
units: imported.map((r) => ({
containerNumber: r.containerNumber,
sealNumber: r.sealNumber,
vgmTons: r.vgmTons,
isHazardous: Boolean(r.hazardous),
isReefer: Boolean(r.reefer),
isReturn: Boolean(r.withReturn),
})),
};
});
@@ -1511,6 +1519,16 @@ function NotesSection({ form }: { form: ShipmentForm }) {
);
}
/** A blank container row — handling switches start off. */
const emptyUnit = () => ({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
function ContainerLineEditor({
form,
index,
@@ -1536,76 +1554,81 @@ function ContainerLineEditor({
const current = form.getValues(`containers.${index}.units`) ?? [];
const next = [...current];
while (next.length < qty)
next.push({ containerNumber: "", sealNumber: "", vgmTons: "" });
next.push({
containerNumber: "",
sealNumber: "",
vgmTons: "",
isHazardous: false,
isReefer: false,
isReturn: false,
});
next.length = Math.max(0, qty);
form.setValue(`containers.${index}.units`, next, { shouldValidate: false });
syncHandlingCounts(next);
};
// Lowering the line quantity must pull every cargo-handling count back within
// it, or a stale count silently exceeds the line and fails validation on a
// field the customer can no longer see a cause for.
const clampHandlingCounts = (qty: number) => {
(["hazardousQuantity", "reeferQuantity", "returnQuantity"] as const).forEach(
(key) => {
const path = `containers.${index}.${key}` as const;
const current = Number(form.getValues(path) || 0);
if (current > qty)
form.setValue(path, String(Math.max(0, qty)), {
shouldDirty: true,
shouldValidate: true,
});
},
);
/**
* Line totals are a roll-up of the per-container switches — the count is
* however many containers ticked each service. Kept in form state so the
* price estimate and the submitted payload stay in step with the switches.
*/
const syncHandlingCounts = (
units: Array<{ isHazardous?: boolean; isReefer?: boolean; isReturn?: boolean }>,
) => {
const set = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
count: number,
) =>
form.setValue(`containers.${index}.${key}`, String(count), {
shouldDirty: true,
shouldValidate: true,
});
set("hazardousQuantity", units.filter((u) => u.isHazardous).length);
set("reeferQuantity", units.filter((u) => u.isReefer).length);
set("returnQuantity", units.filter((u) => u.isReturn).length);
};
/** Switch state is derived from the count — a line is hazardous iff qty > 0. */
const handlingToggle = (
key: "hazardousQuantity" | "reeferQuantity" | "returnQuantity",
opts: {
icon: ReactNode;
iconBg: string;
iconColor: string;
title: string;
description: string;
pickLabel: string;
activeBg: string;
activeBorder: string;
activeColor: string;
/** Flip one container's handling switch, then re-roll the line totals. */
const toggleUnitHandling = (
unitIndex: number,
key: "isHazardous" | "isReefer" | "isReturn",
on: boolean,
) => {
form.setValue(`containers.${index}.units.${unitIndex}.${key}`, on, {
shouldDirty: true,
});
syncHandlingCounts(form.getValues(`containers.${index}.units`) ?? []);
};
/**
* The handling columns offered on each container row — only the services this
* contract was created with, since the server rejects quantities for the others.
*/
const handlingColumns = [
isHazardous && {
key: "isHazardous" as const,
label: "Hazardous",
icon: <Flame size={14} />,
color: "#C0392B",
},
) => (
<Controller
name={`containers.${index}.${key}`}
control={form.control}
render={({ field, fieldState }) => (
<ToggleRow
icon={opts.icon}
iconBg={opts.iconBg}
iconColor={opts.iconColor}
title={opts.title}
description={opts.description}
checked={Number(field.value || 0) > 0}
onChange={(on) => field.onChange(on ? "1" : "0")}
>
<div>
<UnitCountToggles
total={quantity}
value={field.value ?? "0"}
onChange={field.onChange}
label={opts.pickLabel}
activeBg={opts.activeBg}
activeBorder={opts.activeBorder}
activeColor={opts.activeColor}
/>
{fieldState.error?.message ? (
<Text fz={11} c="red.7" mt={4}>
{fieldState.error.message}
</Text>
) : null}
</div>
</ToggleRow>
)}
/>
);
isReefer && {
key: "isReefer" as const,
label: "Refrigerated",
icon: <Snowflake size={14} />,
color: "#2E5B96",
},
withReturnService && {
key: "isReturn" as const,
label: "With return",
icon: <Repeat size={14} />,
color: "#0A6F4D",
},
].filter(Boolean) as Array<{
key: "isHazardous" | "isReefer" | "isReturn";
label: string;
icon: ReactNode;
color: string;
}>;
return (
<Box
@@ -1633,64 +1656,53 @@ function ContainerLineEditor({
field.onChange(e.currentTarget.value);
const qty = Number(e.currentTarget.value || 0);
syncUnits(qty);
clampHandlingCounts(qty);
}}
/>
)}
/>
</Box>
{/* Cargo handling — only the services this contract was created with are
offered, since the server rejects quantities for the others. Each
switch reveals a bounded picker: tap the containers it applies to. */}
{(isHazardous || isReefer || withReturnService) && quantity > 0 && (
<>
<StepLabel>Cargo handling</StepLabel>
<div className="grid gap-3 sm:grid-cols-2" style={{ marginBottom: 14 }}>
{isHazardous &&
handlingToggle("hazardousQuantity", {
icon: <Flame size={18} />,
iconBg: "#FBEAE7",
iconColor: "#C0392B",
title: "Hazardous",
description: "Some of these containers carry hazardous cargo.",
pickLabel: "Tap the hazardous containers",
activeBg: "#FBEAE7",
activeBorder: "#E4A69B",
activeColor: "#C0392B",
})}
{isReefer &&
handlingToggle("reeferQuantity", {
icon: <Snowflake size={18} />,
iconBg: "#E9F0F8",
iconColor: "#2E5B96",
title: "Refrigerated",
description: "Some of these containers need reefer transport.",
pickLabel: "Tap the refrigerated containers",
activeBg: "#E9F0F8",
activeBorder: "#A9C2E0",
activeColor: "#2E5B96",
})}
{withReturnService &&
handlingToggle("returnQuantity", {
icon: <Repeat size={18} />,
iconBg: "#ECF6F1",
iconColor: "#0A6F4D",
title: "With return",
description: "Some of these containers come back to EDR empty.",
pickLabel: "Tap the containers EDR returns",
activeBg: "#ECF6F1",
activeBorder: "#A9D6C2",
activeColor: "#0A6F4D",
})}
</div>
</>
)}
<StepLabel>Per-container details</StepLabel>
{handlingColumns.length > 0 && quantity > 0 ? (
<Text fz={11} c="#5B6B7B" mt={4}>
Tick the services each individual container needs charges apply only
to the containers you tick.
</Text>
) : null}
<Stack gap={10} mt={8}>
{/* Header row — input labels + handling-service labels, one aligned
grid shared by every unit row below. */}
{Math.max(quantity, units.length) > 0 && (
<Group gap={10} wrap="nowrap" align="flex-end">
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Container number *
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
Seal number
</Text>
<Text fz={12} fw={600} c="#10202F" style={{ flex: 1 }}>
VGM (tons) *
</Text>
{handlingColumns.map((col) => (
<Group
key={col.key}
gap={4}
wrap="nowrap"
justify="center"
style={{ width: 96, flexShrink: 0 }}
>
<span style={{ color: col.color, display: "flex" }}>
{col.icon}
</span>
<Text fz={12} fw={600} c="#10202F">
{col.label}
</Text>
</Group>
))}
</Group>
)}
{Array.from({ length: Math.max(quantity, units.length) }).map((_, u) => (
<Group key={u} gap={10} grow align="flex-start">
<Group key={u} gap={10} wrap="nowrap" align="flex-start">
<Controller
name={`containers.${index}.units.${u}.containerNumber`}
control={form.control}
@@ -1700,11 +1712,11 @@ function ContainerLineEditor({
onChange={(e) =>
field.onChange(e.currentTarget.value.toUpperCase())
}
label={u === 0 ? "Container number *" : undefined}
placeholder="e.g. MSCU1234567"
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
@@ -1714,10 +1726,10 @@ function ContainerLineEditor({
render={({ field }) => (
<TextInput
{...field}
label={u === 0 ? "Seal number" : undefined}
placeholder="Optional"
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
@@ -1729,16 +1741,44 @@ function ContainerLineEditor({
{...field}
type="number"
onKeyDown={blockNegative}
label={u === 0 ? "VGM (tons) *" : undefined}
placeholder="e.g. 24.5"
min={0}
step={0.01}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
style={{ flex: 1 }}
/>
)}
/>
{handlingColumns.map((col) => (
<Controller
key={col.key}
name={`containers.${index}.units.${u}.${col.key}`}
control={form.control}
render={({ field }) => (
<Box
style={{
width: 96,
flexShrink: 0,
height: 42,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Switch
checked={Boolean(field.value)}
aria-label={`${col.label} — container ${u + 1}`}
onChange={(e) =>
toggleUnitHandling(u, col.key, e.currentTarget.checked)
}
size="sm"
/>
</Box>
)}
/>
))}
</Group>
))}
</Stack>

View File

@@ -0,0 +1,95 @@
/*
* Scoped to .edr-contracts-table — every rule below is prefixed, so no other
* Mantine Table in the portal is affected.
*
* Column sizing: table-layout stays `auto`. Every column sizes to its content
* with a 30px floor and no wrapping — when the columns together outgrow the
* viewport, the table widens and the wrapper's overflow-x takes over.
*/
/*
* width: max-content — the table is exactly as wide as its columns' content
* needs, never squeezed to fit the viewport; the overflow-x wrapper scrolls
* instead. min-width: 100% keeps it filling the card when content is narrow.
*/
.edr-contracts-table {
table-layout: auto;
width: max-content;
min-width: 100%;
}
.edr-contracts-table th,
.edr-contracts-table td {
min-width: 40px;
}
/*
* Mantine Badge caps itself at max-width: 100%; inside an auto-layout table
* cell that resolves against min-content and clips the label ("General" →
* "Gen…"). Let the badge size to its text so the column grows to fit it.
*/
.edr-contracts-table .mantine-Badge-root {
max-width: none;
}
/*
* Full-width rows (loading / error / empty / expanded step banner) span every
* column via colspan — leave their wrapping alone.
*/
.edr-contracts-table th,
.edr-contracts-table td:not([colspan]) {
white-space: nowrap;
}
/*
* Action column hugs its content: width 1% + nowrap makes the browser give it
* the minimum width its buttons need and nothing more.
*/
.edr-contracts-table th:last-child,
.edr-contracts-table td:last-child:not([colspan]) {
width: 1%;
}
/* Sticky header row (moved off the Mantine `styles` prop — see ContractsList). */
.edr-contracts-table thead th {
position: sticky;
top: 0;
z-index: 1;
}
/*
* Sticky action column. `:not([colspan])` keeps the full-width rows — loading,
* error, empty state, and the expanded ContractStepBanner row — out of it;
* those span every column and have no separate action cell to pin.
*/
.edr-contracts-table th:last-child,
.edr-contracts-table td:last-child:not([colspan]) {
position: sticky;
right: 0;
box-shadow: -12px 0 16px -6px rgba(16, 32, 47, 0.3);
}
/*
* Sticky cells sit above the scrolling ones, so they need their own opaque
* background or the columns underneath show through. Each state below mirrors
* the background the row already has.
*/
.edr-contracts-table td:last-child:not([colspan]) {
/* Very light blue-grey tint sets the action column apart from the rows. */
background: #f5f8fb;
z-index: 2;
}
.edr-contracts-table tbody tr:hover td:last-child:not([colspan]) {
background: #f4fbf8;
}
/* Expanded row: the parent <tr> carries an inline #F4FBF8 background. */
.edr-contracts-table tbody tr[data-expanded="true"] td:last-child:not([colspan]) {
background: #f4fbf8;
}
/* Header cell is sticky on both axes — it must outrank the body's sticky column. */
.edr-contracts-table th:last-child {
background: #f8fafc;
z-index: 3;
}

View File

@@ -119,7 +119,10 @@ export function contractToFormValues(
enabledContainerSizes as ContractFormInputValues["enabledContainerSizes"],
containerSizeCaps,
cargoTypePath,
cargoFreeText: bulkRow?.cargoFreeText ?? "",
// Bulk: the commodity free-text; container: the required cargo
// description (stored on every size row — read the first).
cargoFreeText:
(isContainer ? scope[0]?.cargoFreeText : bulkRow?.cargoFreeText) ?? "",
bulkQuantityCap:
isGeneral && bulkRow?.quantityCap != null ? bulkRow.quantityCap : 0,
isHazardous: contract.isHazardous,

View File

@@ -11,14 +11,6 @@ export const CONTRACT_STEPS = [
{ id: 2, label: "Review & Submit", short: "Review" },
] as const;
/** Edit flow (CHANGES_REQUESTED): documents on step 2, review on step 3. */
export const EDIT_CONTRACT_STEPS = [
{ id: 0, label: "Setup", short: "Setup" },
{ id: 1, label: "Cargo & Route", short: "Cargo & Route" },
{ id: 2, label: "Documents", short: "Documents" },
{ id: 3, label: "Review & Submit", short: "Review" },
] as const;
export const OPERATION_TYPES = [
"import",
"export",
@@ -239,6 +231,14 @@ export const contractFormSchema = z
message: "Enable at least one container size.",
});
}
// Containerized cargo must say WHAT is inside — required description.
if (!data.cargoFreeText.trim()) {
ctx.addIssue({
code: "custom",
path: ["cargoFreeText"],
message: "Describe the cargo carried in the containers.",
});
}
}
if (data.cargoType === "bulk") {
// Bulk scope: a commodity is required.
@@ -330,15 +330,3 @@ export const contractStepFields: Record<
// company profile documents are attached to the contract automatically.)
2: ["notes"],
};
/** Field validation per step when editing a CHANGES_REQUESTED contract. */
export const editContractStepFields: Record<
number,
Array<Path<ContractFormValues>>
> = {
0: contractStepFields[0],
1: contractStepFields[1],
// Step 2 — Documents: validated via missingRequiredDocKeys in the wizard.
2: [],
3: ["notes"],
};

View File

@@ -1,15 +1,16 @@
import { useEffect, useMemo, useRef } from "react";
import { Controller, type UseFormReturn } from "react-hook-form";
import { Flame, RotateCcw, Snowflake } from "lucide-react";
import { Check, Container, Flame, RotateCcw, Snowflake } from "lucide-react";
import {
Box,
Group,
MultiSelect,
Select,
Skeleton,
Stack,
Switch,
Text,
Textarea,
UnstyledButton,
} from "@mantine/core";
import type { Freight } from "@edr/types";
import {
@@ -18,9 +19,21 @@ import {
} from "./schema";
import { fieldStyles, SelectField, StepLabel } from "./shared";
const CONTAINER_SIZE_OPTIONS = [
{ value: "20ft", label: "20ft Container (TEU)" },
{ value: "40ft", label: "40ft Container (FEU)" },
const CONTAINER_SIZE_OPTIONS: Array<{
value: "20ft" | "40ft";
label: string;
description: string;
}> = [
{
value: "20ft",
label: "20ft Container",
description: "Standard twenty-foot unit (TEU)",
},
{
value: "40ft",
label: "40ft Container",
description: "Standard forty-foot unit (FEU)",
},
];
const CARGO_TYPE_OPTIONS = [
@@ -115,6 +128,9 @@ export function Step3CargoScope({
onChange={(v) => {
if (!v) return;
field.onChange(v);
// cargoFreeText is shared (bulk commodity label / container
// description) — clear it so text never carries across types.
form.setValue("cargoFreeText", "", { shouldDirty: true });
if (v === "container") {
form.setValue("cargoTypePath", [], { shouldDirty: true });
} else {
@@ -134,34 +150,78 @@ export function Step3CargoScope({
)}
/>
{/* Container scope: enabled sizes as a multi-select. */}
{cargoType === "container" && (
<Controller
name="enabledContainerSizes"
control={form.control}
render={({ field, fieldState }) => (
<MultiSelect
label="Container sizes in scope *"
placeholder={
(field.value ?? []).length ? undefined : "Select sizes…"
}
data={CONTAINER_SIZE_OPTIONS}
value={field.value ?? []}
onChange={(v) =>
field.onChange(v as ("20ft" | "40ft")[])
}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
checkIconPosition="right"
comboboxProps={{ withinPortal: true, shadow: "md", radius: "md" }}
styles={fieldStyles}
/>
)}
/>
)}
</div>
{/* Container scope: enabled sizes as tick-cards — tap to toggle, one or
both can be in scope. Clearer than a multi-select for two options. */}
{cargoType === "container" && (
<Controller
name="enabledContainerSizes"
control={form.control}
render={({ field, fieldState }) => {
const selected = (field.value ?? []) as ("20ft" | "40ft")[];
const toggle = (size: "20ft" | "40ft") => {
field.onChange(
selected.includes(size)
? selected.filter((s) => s !== size)
: [...selected, size],
);
field.onBlur();
};
return (
<Box>
<StepLabel>Container sizes in scope *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={2}>
Tick every size this contract should cover you can select
both.
</Text>
<div className="mt-3 grid gap-3 sm:grid-cols-2">
{CONTAINER_SIZE_OPTIONS.map((opt) => (
<SizeCard
key={opt.value}
label={opt.label}
description={opt.description}
checked={selected.includes(opt.value)}
hasError={Boolean(fieldState.error)}
onToggle={() => toggle(opt.value)}
/>
))}
</div>
{fieldState.error?.message && (
<Text fz={12} c="red.7" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
);
}}
/>
)}
{/* Container scope: required description of what the containers carry. */}
{cargoType === "container" && (
<Controller
name="cargoFreeText"
control={form.control}
render={({ field, fieldState }) => (
<Textarea
label="Cargo description *"
description="What will the containers carry under this contract?"
placeholder="e.g. Electronics, garments, machinery spare parts…"
value={field.value ?? ""}
onChange={(e) => field.onChange(e.currentTarget.value)}
onBlur={field.onBlur}
error={fieldState.error?.message}
radius={10}
autosize
minRows={2}
maxRows={4}
styles={fieldStyles}
/>
)}
/>
)}
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
{cargoType === "bulk" && (
<Stack gap={12} mt={18}>
@@ -270,6 +330,85 @@ export function Step3CargoScope({
);
}
/** Checkbox-style card for one container size. Whole card toggles. */
function SizeCard({
label,
description,
checked,
hasError,
onToggle,
}: {
label: string;
description: string;
checked: boolean;
hasError: boolean;
onToggle: () => void;
}) {
return (
<UnstyledButton
role="checkbox"
aria-checked={checked}
aria-label={label}
onClick={onToggle}
px={16}
py={13}
style={{
borderRadius: 14,
border: `1.5px solid ${
checked ? "#0A6F4D" : hasError ? "#E8B4AC" : "#E6ECF2"
}`,
background: checked ? "#F6FBF8" : "#fff",
transition: "all 150ms ease",
width: "100%",
}}
>
<Group gap={13} align="center" wrap="nowrap">
<Box
style={{
width: 22,
height: 22,
borderRadius: 7,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
border: `1.5px solid ${checked ? "#0A6F4D" : "#C7D2DC"}`,
background: checked ? "#0A6F4D" : "#fff",
color: "#fff",
transition: "all 150ms ease",
}}
>
{checked && <Check size={14} strokeWidth={3} />}
</Box>
<Box
style={{
width: 38,
height: 38,
borderRadius: 11,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: checked ? "#EAF6EC" : "#F1F5F8",
color: checked ? "#1E7B34" : "#6B7C8E",
transition: "all 150ms ease",
}}
>
<Container size={18} />
</Box>
<Box style={{ textAlign: "left" }}>
<Text fz={14} fw={700} c="#10202F">
{label}
</Text>
<Text fz={12} c="#6B7C8E" style={{ lineHeight: 1.4 }}>
{description}
</Text>
</Box>
</Group>
</UnstyledButton>
);
}
function ToggleRow({
icon,
iconBg,

View File

@@ -345,7 +345,7 @@ export function Step8Review({
value={
<>
{cargoValue || "—"}
{values.cargoType === "bulk" && values.cargoFreeText?.trim() && (
{values.cargoFreeText?.trim() && (
<Text fz="sm" c="dimmed" mt={4}>
{values.cargoFreeText}
</Text>

View File

@@ -48,6 +48,11 @@ const containerUnitSchema = z.object({
.string()
.refine((v) => v.trim().length > 0, "VGM is required.")
.refine((v) => !Number.isNaN(Number(v)) && Number(v) > 0, "Enter a valid VGM."),
// Handling is per physical container, recorded next to its VGM. The line
// totals below are derived from these.
isHazardous: z.boolean().default(false),
isReefer: z.boolean().default(false),
isReturn: z.boolean().default(false),
});
const containerLineSchema = z.object({