mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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` : "—"}
|
||||
|
||||
@@ -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)`;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) ?? "∞"}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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) : "";
|
||||
};
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)" } }}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 → 2–3 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user