This commit is contained in:
Marshal
2026-07-07 12:28:47 +00:00
parent 1c18fdbd52
commit f300600bfa
63 changed files with 2587 additions and 428 deletions

View File

@@ -18,6 +18,7 @@ const statusColorMap: Record<string, string> = {
EXPIRED: "red",
PAID: "edr-green",
IN_TRANSIT: "cyan",
ARRIVED: "teal",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",

View File

@@ -630,6 +630,20 @@ function DocReviewCard({
</Button>
</Tooltip>
)}
{hasFile && (
<Tooltip label="Download">
<Button
component="a"
href={fileViewUrl(doc.file!.id, true)}
size="compact-xs"
variant="default"
radius="md"
leftSection={<Download size={13} />}
>
Download
</Button>
</Tooltip>
)}
</Group>
</Group>

View File

@@ -727,9 +727,9 @@ function ImportT1UploadStep({
);
}
const departed = Boolean(t1.trainDepartedAt);
const canUpload =
canDjAct && t1.wagonAllocated && gatepassGranted && !departed && !t1.closed;
// Departure no longer locks T1 docs — GL DJ may replace them until GL Ethiopia
// closes/accepts the T1.
const canUpload = canDjAct && t1.wagonAllocated && gatepassGranted && !t1.closed;
return (
<Stack gap="sm">
@@ -769,10 +769,6 @@ function ImportT1UploadStep({
pendingLabel="Waiting for the gate pass to be secured on the train schedule."
doneLabel=""
/>
) : departed ? (
<Alert color="orange" variant="light" icon={<AlertTriangle size={16} />}>
The train has departed T1 documents are locked and can no longer be changed.
</Alert>
) : uploaded.length === 0 && !canUpload ? (
<StepStatus
done={false}

View File

@@ -177,6 +177,7 @@ const BOOKING_STATUS_COLOR: Record<CustomerBookingStatus, string> = {
APPROVED: "cyan",
PAID: "edr-green",
IN_TRANSIT: "blue",
ARRIVED: "teal",
COMPLETED: "indigo",
REJECTED: "red",
CANCELLED: "red",

View File

@@ -33,7 +33,9 @@ const FleetRecordActions = ({
const isVehicle = config.slug === "vehicles";
const showHistory =
Boolean(onHistory) &&
(config.slug === "drivers" || config.slug === "vehicles");
(config.slug === "drivers" ||
config.slug === "vehicles" ||
config.slug === "wagons");
const handleDetail = () => {
if (!config.detailPath || !("id" in record)) return;

View File

@@ -0,0 +1,133 @@
import type { ReactNode } from "react";
import { Badge, Center, Group, Loader, Modal, Text, Timeline } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { ArrowRight, PackageCheck, TrainFront, Wrench } from "lucide-react";
import { api } from "@/services/api";
import type { FleetRecord } from "@/services/fleet/fleet.service";
import type { WagonMovementRecord } from "@/services/wagon.service";
export interface WagonMovementHistoryModalProps {
opened: boolean;
onClose: () => void;
record: FleetRecord | null;
}
const asObj = (r: FleetRecord | null) => (r ?? {}) as Record<string, unknown>;
/** Chip style per wagon_movements ledger kind. */
const KIND_META: Record<string, { label: string; color: string; icon: ReactNode }> = {
LOADED: {
label: "Loaded leg",
color: "edr-green",
icon: <PackageCheck size={14} />,
},
EMPTY_REPOSITION: {
label: "Empty reposition",
color: "blue",
icon: <TrainFront size={14} />,
},
MANUAL: {
label: "Manual move",
color: "orange",
icon: <Wrench size={14} />,
},
};
const yardLabel = (
yard: { label?: string; code?: string } | null | undefined,
yardId: string | null,
) => yard?.label ?? yard?.code ?? yardId ?? "Unknown";
const fmt = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
/**
* Movement ledger for one wagon: every relocation between yards — booking legs,
* empty reposition rides, and manual staff corrections — newest first.
*/
const WagonMovementHistoryModal = ({
opened,
onClose,
record,
}: WagonMovementHistoryModalProps) => {
const r = asObj(record);
const id = r.id ? String(r.id) : "";
const wagonNumber = r.wagonNumber ? String(r.wagonNumber) : "";
const { data, isLoading } = useQuery(
api.wagons.movements.queryOptions({
input: { id },
enabled: opened && Boolean(id),
}),
);
const movements: WagonMovementRecord[] = data ?? [];
return (
<Modal
opened={opened}
onClose={onClose}
title={<Text fw={600}>{`Wagon history — ${wagonNumber}`.trim()}</Text>}
radius="lg"
size="lg"
centered
>
{isLoading ? (
<Center py="xl">
<Loader size="sm" />
</Center>
) : movements.length === 0 ? (
<Text c="dimmed" ta="center" py="lg" size="sm">
No movements recorded yet. Every yard-to-yard move appears here a
booking's loaded leg, an empty reposition ride, or a manual correction.
</Text>
) : (
<Timeline active={movements.length} bulletSize={24} lineWidth={2}>
{movements.map((movement) => {
const meta = KIND_META[movement.kind] ?? {
label: movement.kind,
color: "gray",
icon: <TrainFront size={14} />,
};
const from = yardLabel(movement.fromYard, movement.fromYardId);
const to = yardLabel(movement.toYard, movement.toYardId);
return (
<Timeline.Item
key={movement.id}
bullet={meta.icon}
title={
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{from}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>
</Group>
}
>
{movement.note && (
<Text size="sm" c="dimmed">
{movement.note}
</Text>
)}
<Text size="xs" mt={4} c="dimmed">
{fmt(movement.occurredAt)}
</Text>
</Timeline.Item>
);
})}
</Timeline>
)}
</Modal>
);
};
export default WagonMovementHistoryModal;

View File

@@ -0,0 +1,333 @@
import {
Alert,
Badge,
Button,
Divider,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, MapPin, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { Freight } from "@edr/types";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
import type { YardWorkBookingRow, YardWorkYard } from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
const fmtDate = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString();
};
const DIRECTION_COLORS: Record<string, string> = {
IMPORT: "blue",
EXPORT: "teal",
DOMESTIC: "violet",
};
/** DOMESTIC displays as "Intercity" — shared with the rest of the platform. */
const DIRECTION_LABELS: Record<string, string> = Freight.TRADE_DIRECTION_LABELS;
function DirectionChip({ direction }: { direction: string }) {
return (
<Badge size="sm" variant="light" color={DIRECTION_COLORS[direction] ?? "gray"}>
{DIRECTION_LABELS[direction] ?? direction}
</Badge>
);
}
function BookingCell({ row }: { row: YardWorkBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
);
}
function WorkTable({
rows,
side,
trainHere,
onLoad,
onUnload,
pendingBookingId,
}: {
rows: YardWorkBookingRow[];
side: "load" | "unload";
trainHere: boolean;
onLoad: (bookingId: string) => void;
onUnload: (bookingId: string) => void;
pendingBookingId: string | null;
}) {
if (rows.length === 0) {
return (
<Text size="sm" c="dimmed">
{side === "load" ? "No bookings board here." : "No bookings alight here."}
</Text>
);
}
return (
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Direction</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>{side === "load" ? "Loaded" : "Arrived"}</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const timestamp = side === "load" ? row.loadedAt : row.arrivedAt;
const canAct = side === "load" ? row.canLoad : row.canUnload;
return (
<Table.Tr key={row.id}>
<Table.Td>
<BookingCell row={row} />
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<DirectionChip direction={row.tradeDirection} />
</Table.Td>
<Table.Td>
<BookingStatusBadge status={row.status} />
</Table.Td>
<Table.Td>
{timestamp ? (
<Text size="xs" c="dimmed">
{fmtDate(timestamp)}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{side === "load" ? (
<Tooltip
label={
trainHere
? "Confirm cargo loaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.loadedAt)}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onLoad(row.id)}
>
Load
</Button>
</Tooltip>
) : (
<Tooltip
label={
trainHere
? "Confirm cargo unloaded at this yard"
: "Train must be at this yard"
}
disabled={!canAct && Boolean(row.arrivedAt)}
>
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
disabled={!canAct || !trainHere}
loading={pendingBookingId === row.id}
onClick={() => onUnload(row.id)}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
);
}
/**
* Per-yard load/unload worklist for one schedule — every trade direction. Each
* booking boards at its origin yard and alights at its destination yard; the
* operator confirms both while the train's last recorded checkpoint is at that
* yard (the server validates the position). Unloading stamps the booking's own
* arrival — ARRIVED for import/export, COMPLETED for intercity.
*/
export function YardWorkPanel({ scheduleId }: { scheduleId: string }) {
const { toast } = useToast();
const queryClient = useQueryClient();
const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.yardWork.queryKey({ scheduleId }),
});
const load = useMutation(
api.trainScheduling.loadScheduleBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadScheduleBooking.mutationOptions({
onSuccess: (result) => {
void invalidate();
toast({
title:
result.status === "COMPLETED"
? "Cargo unloaded — booking completed"
: "Cargo unloaded — booking arrived",
});
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
const data = yardWorkQuery.data;
const yards: YardWorkYard[] = data?.yards ?? [];
const trainAtYardId = data?.trainAtYardId ?? null;
const pendingLoadId = load.isPending ? (load.variables?.bookingId ?? null) : null;
const pendingUnloadId = unload.isPending ? (unload.variables?.bookingId ?? null) : null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group gap="xs">
<MapPin size={18} />
<Text fw={700}>Yard load / unload</Text>
</Group>
{yardWorkQuery.isLoading ? (
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading yard worklists
</Text>
</Group>
) : yardWorkQuery.isError ? (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(yardWorkQuery.error, "Could not load the yard worklist")}
</Alert>
) : yards.length === 0 ? (
<Text size="sm" c="dimmed">
No bookings are assigned to this schedule yet.
</Text>
) : (
<>
<Text size="sm" c="dimmed">
What boards and alights at each stop. Confirm loading at a booking's
origin and unloading at its destination while the train is at that
yard — unloading stamps the booking's own arrival, even before the
train's final stop.
</Text>
{yards.map((yard, index) => {
const trainHere = trainAtYardId === yard.yardId;
return (
<Stack key={yard.yardId} gap="sm">
{index > 0 && <Divider />}
<Group gap="xs">
<Text fw={600}>{yard.yard}</Text>
{trainHere && (
<Badge
size="sm"
variant="light"
color="edr-green"
leftSection={<TrainFront size={12} />}
>
Train here
</Badge>
)}
</Group>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Board here
</Text>
<WorkTable
rows={yard.toLoad}
side="load"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingLoadId}
/>
</Stack>
<Stack gap={6}>
<Text size="sm" fw={600} c="dimmed">
Alight here
</Text>
<WorkTable
rows={yard.toUnload}
side="unload"
trainHere={trainHere}
onLoad={(bookingId) => load.mutate({ scheduleId, bookingId })}
onUnload={(bookingId) => unload.mutate({ scheduleId, bookingId })}
pendingBookingId={pendingUnloadId}
/>
</Stack>
</Stack>
);
})}
</>
)}
</Stack>
</Paper>
);
}

View File

@@ -326,6 +326,11 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
YARD_WORK: (id: string) => `/train-scheduling/schedules/${id}/yard-work`,
BOOKING_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/load`,
BOOKING_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/bookings/${bookingId}/unload`,
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>

View File

@@ -66,6 +66,10 @@ export const BOOKING_STATUS_STYLES: Record<string, StatusStyle> = {
label: "In Transit",
color: "bg-sky-50 text-sky-700 border-sky-200",
},
ARRIVED: {
label: "Arrived",
color: "bg-emerald-50 text-emerald-700 border-emerald-200",
},
COMPLETED: {
label: "Completed",
color: "bg-indigo-50 text-indigo-700 border-indigo-200",
@@ -208,6 +212,12 @@ export const BOOKING_STATUS_META: Record<string, StatusMeta> = {
color: "text-sky-600",
stage: 4,
},
ARRIVED: {
title: "Arrived",
description: "Cargo unloaded at its destination yard.",
color: "text-emerald-600",
stage: 4,
},
COMPLETED: {
title: "Completed",
description: "Booking fulfilled.",
@@ -290,7 +300,7 @@ export const BOOKING_LIST_TABS = [
{
key: "operations",
label: "Operations",
statuses: ["PAID", "IN_TRANSIT", "ROAD_DISPATCH_PENDING"],
statuses: ["PAID", "IN_TRANSIT", "ARRIVED", "ROAD_DISPATCH_PENDING"],
},
{ key: "completed", label: "Completed", statuses: ["COMPLETED"] },
{ key: "closed", label: "Closed", statuses: ["REJECTED", "CANCELLED"] },
@@ -328,7 +338,7 @@ export const WORKFLOW_STAGES = [
},
{
label: "Operations",
statuses: ["PAID", "IN_TRANSIT"],
statuses: ["PAID", "IN_TRANSIT", "ARRIVED"],
},
{ label: "Done", statuses: ["COMPLETED"] },
] as const;

View File

@@ -13,6 +13,7 @@ import FleetFormDialog from "@/components/fleet/FleetFormDialog";
import FleetHistoryModal from "@/components/fleet/FleetHistoryModal";
import FleetRecordActions from "@/components/fleet/FleetRecordActions";
import FleetToolbar from "@/components/fleet/FleetToolbar";
import WagonMovementHistoryModal from "@/components/fleet/WagonMovementHistoryModal";
import { formatFleetCell, registerFleetOptionLabels } from "@/components/fleet/fleetFormat";
import { useFleetViewMode } from "@/components/fleet/useFleetViewMode";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
@@ -585,12 +586,20 @@ const FleetResourcePage = () => {
</Stack>
</Modal>
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
{slug === "wagons" ? (
<WagonMovementHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
record={historyTarget}
/>
) : (
<FleetHistoryModal
opened={Boolean(historyTarget)}
onClose={() => setHistoryTarget(null)}
entity={slug === "vehicles" ? "vehicle" : "driver"}
record={historyTarget}
/>
)}
</Container>
);
};

View File

@@ -92,6 +92,12 @@ const TRADE_DIRECTIONS = [
{ label: "Both", value: "BOTH" },
];
// Mirrors the YardCountry enum in @edr/types — the only two countries on the line.
const YARD_COUNTRIES = [
{ label: "Ethiopia", value: "Ethiopia" },
{ label: "Djibouti", value: "Djibouti" },
];
const APPROVAL_ROLES = [
{ label: "Line staff", value: "LINE_STAFF" },
{ label: "Director", value: "DIRECTOR" },
@@ -431,7 +437,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
],
formFields: [
{ name: "label", label: "Label", type: "text", required: true },
{ name: "country", label: "Country", type: "text", required: true },
{
name: "country",
label: "Country",
type: "select",
required: true,
options: YARD_COUNTRIES,
},
{ name: "isActive", label: "Active", type: "boolean" },
],
},

View File

@@ -49,6 +49,7 @@ import {
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { YardWorkPanel } from "@/components/trainScheduling/YardWorkPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -1131,6 +1132,7 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch();
}}
/>
{scheduleId ? <YardWorkPanel scheduleId={scheduleId} /> : null}
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}

View File

@@ -180,6 +180,7 @@ import {
wagonService,
type Wagon,
type WagonListFilters,
type WagonMovementRecord,
} from "./wagon.service";
import { warehouseService } from "./warehouse.service";
@@ -593,6 +594,40 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
yardWork: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").YardWorkResult
>(
"train-scheduling",
"yard-work",
({ scheduleId }) => trainSchedulingService.getYardWork(scheduleId),
({ scheduleId }) => ["train-scheduling", "yard-work", scheduleId],
),
loadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingLoadResult
>(
"train-scheduling",
"booking-load",
({ scheduleId, bookingId }) =>
trainSchedulingService.loadScheduleBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadScheduleBooking: endpoint<
{ scheduleId: string; bookingId: string },
import("@/types/trainScheduling").BookingUnloadResult
>(
"train-scheduling",
"booking-unload",
({ scheduleId, bookingId }) =>
trainSchedulingService.unloadScheduleBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
@@ -1493,6 +1528,13 @@ export const api = {
wagonService.getById(id).then((r) => r.data),
),
movements: endpoint<{ id: string }, WagonMovementRecord[]>(
"wagons",
"movements",
({ id }) => wagonService.getMovements(id).then((r) => r.data),
({ id }) => ["wagons", "movements", id],
),
assignToTrain: endpoint<
{ wagonId: string; trainId: string; sequenceNumber?: number },
Wagon

View File

@@ -8,6 +8,8 @@ import type {
BookableSchedule,
BookingWindow,
AssignBookingsPayload,
BookingLoadResult,
BookingUnloadResult,
CompositionRemovalEntry,
UnassignedBookingsResponse,
CreateTrainSchedulePayload,
@@ -35,6 +37,7 @@ import type {
UploadImportDjiboutiDocumentPayload,
WagonAllocationAttemptResult,
YardOption,
YardWorkResult,
} from "@/types/trainScheduling";
interface BookingReferenceDataResponse {
@@ -330,6 +333,35 @@ export const trainSchedulingService = {
return unwrap(response.data);
},
getYardWork: async (scheduleId: string): Promise<YardWorkResult> => {
const response = await client.get<YardWorkResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.YARD_WORK(scheduleId),
);
return unwrap(response.data);
},
loadScheduleBooking: async (
scheduleId: string,
bookingId: string,
): Promise<BookingLoadResult> => {
const response = await client.post<BookingLoadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_LOAD(scheduleId, bookingId),
{},
);
return unwrap(response.data);
},
unloadScheduleBooking: async (
scheduleId: string,
bookingId: string,
): Promise<BookingUnloadResult> => {
const response = await client.post<BookingUnloadResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.BOOKING_UNLOAD(scheduleId, bookingId),
{},
);
return unwrap(response.data);
},
getIntercityCandidates: async (
scheduleId: string,
): Promise<IntercityCandidatesResult> => {

View File

@@ -37,6 +37,27 @@ export interface WagonListFilters {
trainId?: string;
}
/**
* One row of the wagon_movements ledger: every physical relocation between
* yards — a booking's loaded leg, an empty reposition ride, or a manual staff
* correction. Returned newest first by the API.
*/
export interface WagonMovementRecord {
id: string;
wagonId: string;
fromYardId: string | null;
toYardId: string;
fromYard?: { id?: string; label?: string; code?: string } | null;
toYard?: { id?: string; label?: string; code?: string } | null;
trainScheduleId: string | null;
bookingId: string | null;
kind: Freight.WagonMovementKind;
movedByUserId: string | null;
occurredAt: string;
note: string | null;
createdAt: string;
}
export const wagonService = {
getAll: (filters: WagonListFilters = {}) => {
const params = new URLSearchParams();
@@ -49,6 +70,8 @@ export const wagonService = {
return apiClient.get<Wagon[]>(`/wagons${qs ? `?${qs}` : ''}`);
},
getById: (id: string) => apiClient.get<Wagon>(`/wagons/${id}`),
getMovements: (id: string) =>
apiClient.get<WagonMovementRecord[]>(`/wagons/${id}/movements`),
getByTrain: (trainId: string) => apiClient.get<Wagon[]>(`/wagons?trainId=${trainId}`),
assignToTrain: (wagonId: string, trainId: string, sequenceNumber?: number) =>
apiClient.post(`/wagons/${wagonId}/assign-train`, { trainId, sequenceNumber }),

View File

@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
"PAYMENT_VERIFICATION_IN_PROGRESS",
"PAID",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"REJECTED",
"CANCELLED",

View File

@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
| "APPROVED"
| "PAID"
| "IN_TRANSIT"
| "ARRIVED"
| "COMPLETED"
| "REJECTED"
| "CANCELLED";

View File

@@ -796,3 +796,52 @@ export interface IntercityAcceptResult {
rejected: Array<{ bookingId: string; reason: string }>;
remaining: IntercityCapacity;
}
// ── Yard load / unload worklist ──────────────────────────────────────────────
// Per-booking journey along the train's corridor: every booking boards at its
// origin yard and alights at its destination yard, confirmed by the yard
// operator while the train's latest checkpoint is at that yard.
export interface YardWorkBookingRow {
id: string;
reference: string | null;
status: string;
tradeDirection: string;
isGovernment: boolean;
customer: string;
originYardId: string;
destinationYardId: string;
origin: string;
destination: string;
loadedAt: string | null;
arrivedAt: string | null;
canLoad: boolean;
canUnload: boolean;
}
export interface YardWorkYard {
yardId: string;
yard: string;
toLoad: YardWorkBookingRow[];
toUnload: YardWorkBookingRow[];
}
export interface YardWorkResult {
scheduleId: string;
scheduleStatus: string;
trainAtYardId: string | null;
yards: YardWorkYard[];
}
export interface BookingLoadResult {
bookingId: string;
status: string;
loadedAt: string;
}
export interface BookingUnloadResult {
bookingId: string;
/** 'ARRIVED' for import/export, 'COMPLETED' for intercity. */
status: string;
arrivedAt: string;
}

View File

@@ -17,13 +17,15 @@ export const ActivityRow = memo(function ActivityRow({
const verb =
booking.status === "IN_TRANSIT"
? "departed"
: booking.status === "COMPLETED"
? "delivered"
: booking.status === "PENDING_APPROVAL"
? "quote ready"
: booking.status === "SUBMITTED"
? "submitted for review"
: "created";
: booking.status === "ARRIVED"
? "arrived"
: booking.status === "COMPLETED"
? "delivered"
: booking.status === "PENDING_APPROVAL"
? "quote ready"
: booking.status === "SUBMITTED"
? "submitted for review"
: "created";
return (
<Group

View File

@@ -26,6 +26,7 @@ export const ACTIVE_STATUSES = [
"SUBMITTED",
"PENDING_APPROVAL",
"IN_TRANSIT",
"ARRIVED",
];
export interface StageConfig {
@@ -346,6 +347,19 @@ export const STATUS_CONFIG: Record<string, StageConfig> = {
badgeDot: "edr-green.5",
action: { label: "Track", kind: "outline", icon: MapPin },
},
ARRIVED: {
stage: 3,
icon: MapPin,
iconColor: "edr-green.7",
tile: "edr-soft",
hint: "Arrived at destination yard · awaiting release",
step: "edr-green.5",
badgeLabel: "Arrived",
badgeBg: "edr-soft",
badgeText: "edr-green.7",
badgeDot: "edr-green.5",
action: { label: "Track", kind: "outline", icon: MapPin },
},
COMPLETED: {
stage: 4,
icon: CheckCircle2,

View File

@@ -118,7 +118,9 @@ export function ReadonlyBookingView({
const canAssignCustomerTruck =
booking.paymentStatus === "PAID" &&
usesCustomerTruck &&
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
["PAID", "IN_TRANSIT", "ARRIVED", "COMPLETED", "TRUCK_ASSIGNED"].includes(
status,
);
const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";

View File

@@ -66,10 +66,12 @@ export function StatusHero({
}) {
const status = booking.status;
const stage = resolveStage(booking);
// The Arrival stage has no booking status of its own — it lights up from the
// train's ARRIVED state, so the headline is overridden here.
// Legacy bookings never reach the ARRIVED status — they light up the Arrival
// stage from the train's ARRIVED state while staying IN_TRANSIT, so the
// headline is overridden here. Bookings with a per-booking journey carry the
// ARRIVED status themselves and use its own STATUS_MAP copy.
const cfg =
stage === ARRIVAL_STAGE
stage === ARRIVAL_STAGE && STATUS_MAP[status]?.stage !== ARRIVAL_STAGE
? {
title: "Train arrived at destination",
description:

View File

@@ -54,12 +54,13 @@ export const PROGRESS_STAGES = [
statuses: ["EXPIRED", "IN_TRANSIT"],
},
{
// No booking status maps here: the booking stays IN_TRANSIT until
// delivery, so this stage lights up from the assigned train's own status
// ARRIVED: cargo unloaded at the booking's own destination yard (segment
// corridor journeys). Legacy bookings stay IN_TRANSIT until delivery, so
// this stage also lights up from the assigned train's own status
// (trainScheduleStatus === "ARRIVED") — see resolveStage.
label: "Arrival",
icon: MapPin,
statuses: [],
statuses: ["ARRIVED"],
},
{
label: "Complete",
@@ -75,8 +76,10 @@ export const ARRIVAL_STAGE = PROGRESS_STAGES.findIndex(
/**
* Stage for a booking, factoring in the assigned train's operational status:
* a booking is stuck at IN_TRANSIT between dispatch and delivery, so once its
* train has ARRIVED the tracker advances to the Arrival stage.
* a booking with per-booking journey data reaches ARRIVED when it is unloaded
* at its own destination yard; a legacy booking is stuck at IN_TRANSIT between
* dispatch and delivery, so once its train has ARRIVED the tracker advances to
* the Arrival stage.
*/
export function resolveStage(booking: {
status: string;
@@ -177,6 +180,12 @@ export const STATUS_MAP: Record<
description: "Your shipment is currently moving through the rail network.",
stage: 6,
},
ARRIVED: {
title: "Arrived at destination",
description:
"Your cargo has been unloaded at its destination yard and is being prepared for release.",
stage: 7,
},
OPERATION_REQUEST_PENDING: {
title: "Operation request under review",
description:

View File

@@ -58,6 +58,7 @@ import {
const TRACKABLE_STATUSES = new Set([
"PAID",
"IN_TRANSIT",
"ARRIVED",
"COMPLETED",
"DELIVERED",
]);
@@ -83,7 +84,7 @@ const STATUS_FILTERS = [
statuses:
"SELECTED_FOR_BATCH,PNR_GENERATED,PAYMENT_VERIFICATION_IN_PROGRESS,EXPIRED",
},
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT" },
{ key: "transit", label: "In transit", statuses: "PAID,IN_TRANSIT,ARRIVED" },
{ key: "done", label: "Completed", statuses: "COMPLETED,DELIVERED" },
{
key: "closed",

View File

@@ -6,6 +6,7 @@ import {
Clock,
Flag,
MapPin,
PackageCheck,
PackageX,
RefreshCw,
Train,
@@ -15,11 +16,14 @@ import { api } from "@/services/api";
import { Freight } from "@edr/types";
import {
bookingJourneyState,
bookingLegRange,
bookingShipmentStatusLabel,
checkpointKindLabel,
corridorProgress,
isArrived,
isDispatched,
shipmentStatusLabel,
type BookingJourneyState,
} from "./trackingStages";
const GREEN = "#0EA371";
@@ -70,6 +74,7 @@ export function ShipmentTrackingModal({
trainNumber={data?.trainNumber ?? null}
status={data?.scheduleStatus ?? null}
currentSequenceNo={data?.currentSequenceNo ?? -1}
journey={data ? bookingJourneyState(data) : null}
onClose={onClose}
onRefresh={() => refetch()}
refreshing={isFetching}
@@ -95,6 +100,7 @@ export function ShipmentTrackingModal({
) : data ? (
<Stack gap={26}>
<SummaryBar data={data} />
<BookingJourneyLine data={data} />
<Corridor data={data} />
<CheckpointFeed data={data} />
</Stack>
@@ -111,6 +117,7 @@ function Header({
trainNumber,
status,
currentSequenceNo,
journey,
onClose,
onRefresh,
refreshing,
@@ -119,6 +126,7 @@ function Header({
trainNumber: string | null;
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
journey: BookingJourneyState;
onClose: () => void;
onRefresh: () => void;
refreshing: boolean;
@@ -171,7 +179,11 @@ function Header({
</Group>
<Group gap={10} align="center" wrap="nowrap">
<HeaderStatusPill status={status} currentSequenceNo={currentSequenceNo} />
<HeaderStatusPill
status={status}
currentSequenceNo={currentSequenceNo}
journey={journey}
/>
<IconButton title="Refresh" onClick={onRefresh} spinning={refreshing}>
<RefreshCw size={16} />
</IconButton>
@@ -223,12 +235,17 @@ function IconButton({
function HeaderStatusPill({
status,
currentSequenceNo,
journey,
}: {
status: Freight.TrainScheduleStatus | null;
currentSequenceNo: number;
journey: BookingJourneyState;
}) {
const arrived = isArrived(status);
const moving = isDispatched(status);
// The booking's own journey wins: a sub-corridor booking can be unloaded
// (arrived) at its own yard while the train is still moving.
const arrived = journey === "arrived" || (!journey && isArrived(status));
const moving = !arrived && (journey === "in-transit" || isDispatched(status));
const label = bookingShipmentStatusLabel(journey, status, currentSequenceNo);
const bg = arrived
? "rgba(14,163,113,0.22)"
: moving
@@ -254,7 +271,7 @@ function HeaderStatusPill({
}}
/>
<Text fz="12px" fw={700} c="#fff">
{shipmentStatusLabel(status, currentSequenceNo)}
{label}
</Text>
</Group>
);
@@ -263,7 +280,10 @@ function HeaderStatusPill({
// ── Summary bar (ETA / departure / arrival) ────────────────────────────────────
function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
const arrived = isArrived(data.scheduleStatus);
const journey = bookingJourneyState(data);
// Booking-level arrival (unloaded at its own destination yard) counts as
// arrived even while the train itself is still moving down the corridor.
const arrived = journey === "arrived" || isArrived(data.scheduleStatus);
const items: Array<{ label: string; value: string; accent?: boolean }> = [
{
label: "Departed",
@@ -271,7 +291,11 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
},
{
label: arrived ? "Arrived" : "Est. arrival",
value: fmtTime(data.actualArrivalAt ?? data.scheduledArrivalAt),
value: fmtTime(
(journey === "arrived" ? data.arrivedAt : null) ??
data.actualArrivalAt ??
data.scheduledArrivalAt,
),
accent: !arrived,
},
{
@@ -317,6 +341,66 @@ function SummaryBar({ data }: { data: Freight.IBookingTracking }) {
);
}
// ── Per-booking journey line (loaded / unloaded at the booking's own yards) ────
function BookingJourneyLine({ data }: { data: Freight.IBookingTracking }) {
if (!data.loadedAt && !data.arrivedAt) return null;
const stationLabel = (yardId?: string | null) =>
data.stations.find((s) => s.yardId === yardId)?.label ?? null;
const origin = stationLabel(data.bookingOriginYardId) ?? "origin yard";
const destination =
stationLabel(data.bookingDestinationYardId) ?? "destination yard";
return (
<Group gap={10} wrap="wrap">
{data.loadedAt && (
<JourneyChip
icon={<PackageCheck size={14} />}
text={`Loaded at ${origin}`}
time={fmtTime(data.loadedAt)}
/>
)}
{data.arrivedAt && (
<JourneyChip
icon={<CheckCircle2 size={14} />}
text={`Arrived at ${destination}`}
time={fmtTime(data.arrivedAt)}
/>
)}
</Group>
);
}
function JourneyChip({
icon,
text,
time,
}: {
icon: React.ReactNode;
text: string;
time: string;
}) {
return (
<Group
gap={7}
align="center"
wrap="nowrap"
px={12}
py={7}
style={{ borderRadius: 999, background: "#ECF6F1", color: GREEN_DARK }}
>
{icon}
<Text fz="12px" fw={700} c={GREEN_DARK}>
{text}
</Text>
<Text fz="12px" c={MUTED}>
· {time}
</Text>
</Group>
);
}
// ── Corridor: stations + train marker ──────────────────────────────────────────
function Corridor({ data }: { data: Freight.IBookingTracking }) {
@@ -326,6 +410,14 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
const current = data.currentSequenceNo;
const progress = corridorProgress(stations.length, current, arrived);
// The booking's own leg on the corridor (sub-corridor bookings ride only a
// slice of the train's route). Stations outside the leg render dimmed.
const leg = bookingLegRange(
stations,
data.bookingOriginYardId,
data.bookingDestinationYardId,
);
// Map sequenceNo → latest checkpoint at that station for captions.
const checkpointBySeq = new Map<number, Freight.ITrackingCheckpoint>();
for (const c of data.checkpoints) checkpointBySeq.set(c.sequenceNo, c);
@@ -415,6 +507,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
const reached = arrived || (current >= 0 && i <= current);
const isCurrent = !arrived && i === current;
const isLast = i === stations.length - 1;
const onLeg = !leg || (i >= leg.start && i <= leg.end);
const cp = checkpointBySeq.get(s.sequenceNo);
return (
<StationNode
@@ -424,6 +517,7 @@ function Corridor({ data }: { data: Freight.IBookingTracking }) {
isCurrent={isCurrent}
isEndpoint={i === 0 || isLast}
arrivedHere={isLast && arrived}
dimmed={!onLeg}
time={cp ? fmtTime(cp.occurredAt) : null}
align={i === 0 ? "left" : isLast ? "right" : "center"}
/>
@@ -441,6 +535,7 @@ function StationNode({
isCurrent,
isEndpoint,
arrivedHere,
dimmed,
time,
align,
}: {
@@ -449,6 +544,8 @@ function StationNode({
isCurrent: boolean;
isEndpoint: boolean;
arrivedHere: boolean;
/** Station lies outside the booking's own leg — render muted. */
dimmed: boolean;
time: string | null;
align: "left" | "center" | "right";
}) {
@@ -462,6 +559,7 @@ function StationNode({
flex: isEndpoint ? "0 0 auto" : 1,
minWidth: 0,
maxWidth: 120,
opacity: dimmed ? 0.4 : 1,
}}
>
<Box
@@ -488,8 +586,8 @@ function StationNode({
</Box>
<Text
fz="11.5px"
fw={reached ? 700 : 600}
c={reached ? INK : "#9AA8B5"}
fw={!dimmed && reached ? 700 : 600}
c={dimmed ? "#9AA8B5" : reached ? INK : "#9AA8B5"}
mt={8}
ta={align}
truncate

View File

@@ -1,6 +1,6 @@
import { Freight } from "@edr/types";
const { TrainScheduleStatus } = Freight;
const { BookingStatus, TrainScheduleStatus } = Freight;
export function isArrived(
status?: Freight.TrainScheduleStatus | null,
@@ -50,6 +50,65 @@ export function corridorProgress(
return Math.round((Math.min(currentSequenceNo, lastSeq) / lastSeq) * 100);
}
// ── Per-booking journey (segment corridor bookings) ───────────────────────────
/**
* The booking's own journey state, independent of the train: a sub-corridor
* booking is loaded at its own origin yard and unloaded (ARRIVED) at its own
* destination yard while the train may keep going. `null` means the booking
* has no per-booking journey data yet (legacy bookings) — callers fall back
* to the train-schedule status.
*/
export type BookingJourneyState = "arrived" | "in-transit" | null;
export function bookingJourneyState(
t: Freight.IBookingTracking,
): BookingJourneyState {
const status = t.bookingStatus ?? null;
if (
t.arrivedAt ||
status === BookingStatus.Arrived ||
status === BookingStatus.Completed ||
status === BookingStatus.Delivered
) {
return "arrived";
}
if (t.loadedAt) return "in-transit";
return null;
}
/**
* Header pill label. Prefers the booking's own journey (loaded/unloaded at its
* own yards) and falls back to the train-schedule wording for legacy bookings
* without per-booking journey data.
*/
export function bookingShipmentStatusLabel(
journey: BookingJourneyState,
scheduleStatus: Freight.TrainScheduleStatus | null,
currentSequenceNo: number,
): string {
if (journey === "arrived") return "Arrived";
if (journey === "in-transit") return "In transit";
return shipmentStatusLabel(scheduleStatus, currentSequenceNo);
}
/**
* Index range [start..end] of the booking's own leg on the corridor, matched
* by yardId. Null when the booking rides the full corridor (no leg data) or
* either endpoint isn't a station on this train's route.
*/
export function bookingLegRange(
stations: Freight.ITrackingStation[],
originYardId?: string | null,
destinationYardId?: string | null,
): { start: number; end: number } | null {
if (!originYardId || !destinationYardId) return null;
const start = stations.findIndex((s) => s.yardId === originYardId);
const end = stations.findIndex((s) => s.yardId === destinationYardId);
if (start < 0 || end < 0) return null;
return start <= end ? { start, end } : { start: end, end: start };
}
/** Caption for a checkpoint kind. */
export function checkpointKindLabel(kind: Freight.TrainCheckpointKind): string {
switch (kind) {

View File

@@ -13,12 +13,15 @@ import {
import {
ArrowRight,
CalendarClock,
CheckCircle2,
ChevronLeft,
ChevronRight,
Clock,
} from "lucide-react";
import { CountdownTimer } from "@edr/ui-common";
import type { MyBookingWindow } from "@/services/bookings.service";
import { formatWindowOpensAt, soonestUpcomingWindow } from "./booking-window";
const INK = "#10202F";
const MUTED = "#6B7C8E";
@@ -209,6 +212,65 @@ function WindowCard({ w }: { w: MyBookingWindow }) {
);
}
/**
* One-line status strip above the cards: green when a window is open right now
* (the customer can act), neutral with the next opening time otherwise.
*/
function WindowStatusBanner({ windows }: { windows: MyBookingWindow[] }) {
const open = windows.find((w) => w.isOpenNow);
if (open) {
const lane =
open.origin && open.destination
? ` on ${open.origin}${open.destination}`
: "";
return (
<Group
gap={10}
wrap="nowrap"
px={14}
py={10}
mb="md"
style={{
borderRadius: 12,
border: "1px solid #CDEBDD",
background: "#F4FBF7",
}}
>
<CheckCircle2 size={17} color="#0A6F4D" style={{ flexShrink: 0 }} />
<Text fz={13.5} fw={600} c="#0A6F4D">
A booking window is open right now{lane} you can create a shipment
booking before it closes.
</Text>
</Group>
);
}
const next = soonestUpcomingWindow(windows);
return (
<Group
gap={10}
wrap="nowrap"
px={14}
py={10}
mb="md"
style={{
borderRadius: 12,
border: `1px solid ${BORDER}`,
background: "#F8FAFC",
}}
>
<Clock size={17} color={MUTED} style={{ flexShrink: 0 }} />
<Text fz={13.5} fw={600} style={{ color: MUTED }}>
{next?.windowOpensAt
? `Booking is not open yet — the next window opens ${formatWindowOpensAt(
next.windowOpensAt,
)} EAT.`
: "Booking is not open right now. You'll see the opening time here once a window is announced."}
</Text>
</Group>
);
}
interface ContractBookingWindowsSectionProps {
/** Windows already scoped to this contract's routes/direction by the API. */
windows: MyBookingWindow[];
@@ -248,8 +310,6 @@ export function ContractBookingWindowsSection({
safePage * PER_PAGE + PER_PAGE,
);
if (!isLoading && sorted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" style={{ borderColor: BORDER }}>
<Group justify="space-between" align="flex-start" mb="md" wrap="nowrap">
@@ -313,12 +373,39 @@ export function ContractBookingWindowsSection({
<Skeleton key={i} height={150} radius="md" />
))}
</SimpleGrid>
) : sorted.length === 0 ? (
<Stack
align="center"
gap={6}
py={28}
style={{
borderRadius: 12,
border: `1px dashed ${BORDER}`,
background: "#FBFCFE",
}}
>
<CalendarClock size={22} color={MUTED} />
<Text fz={14} fw={600} style={{ color: INK }}>
No booking windows announced yet
</Text>
<Text fz={12.5} ta="center" maw={420} style={{ color: MUTED }}>
When a train is scheduled on this contract&apos;s routes, its
booking window will appear here with the opening time.
</Text>
</Stack>
) : (
<SimpleGrid key={safePage} cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
<>
<WindowStatusBanner windows={sorted} />
<SimpleGrid
key={safePage}
cols={{ base: 1, sm: 2, lg: 3 }}
spacing="md"
>
{visible.map((w) => (
<WindowCard key={`${w.scheduleId}-${w.bookingCycleNo}`} w={w} />
))}
</SimpleGrid>
</>
)}
</Paper>
);

View File

@@ -507,23 +507,17 @@ export default function NewContractPage({
const isContainer = data.cargoType === "container";
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
// size; bulk: a single commodity row.
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
const isGeneral = data.contractKind === "general_contract";
// size; bulk: a single commodity row. Both GENERAL and ONE_TIME are uncapped
// (quantityCap omitted → NULL): the customer books repeatedly against a
// GENERAL contract until its validity expires.
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
quantityCap:
isGeneral && data.containerSizeCaps[size]
? data.containerSizeCaps[size]
: undefined,
}))
: [
{
cargoTypeId: data.cargoTypePath?.[1] || undefined,
cargoFreeText: data.cargoFreeText || undefined,
quantityCap:
isGeneral && data.bulkQuantityCap ? data.bulkQuantityCap : undefined,
},
];

View File

@@ -156,6 +156,7 @@ export const CONTRACT_STATUS_CONFIG: Record<
PNR_GENERATED: { label: "Payment Reference Ready", ...TONE.warning },
PAID: { label: "Paid", ...TONE.success },
IN_TRANSIT: { label: "In Transit", ...TONE.info },
ARRIVED: { label: "Arrived", ...TONE.success },
COMPLETED: { label: "Completed", ...TONE.success },
};

View File

@@ -9,23 +9,27 @@ import { fieldStyles } from "./shared";
export function PaymentCurrencyField({
control,
etbOnly = false,
}: {
control: Control<ContractFormInputValues, any, ContractFormValues>;
/** Intercity (domestic) contracts are priced in ETB only. */
etbOnly?: boolean;
}) {
const options = etbOnly
? PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value === "ETB")
: PAYMENT_CURRENCY_OPTIONS;
return (
<Controller
name="paymentCurrency"
control={control}
render={({ field, fieldState }) => {
const selected = PAYMENT_CURRENCY_OPTIONS.find(
(o) => o.value === field.value,
);
const selected = options.find((o) => o.value === field.value);
return (
<div>
<Select
label="Payment Currency *"
placeholder="Select currency…"
data={PAYMENT_CURRENCY_OPTIONS.map((o) => ({
data={options.map((o) => ({
value: o.value,
label: o.label,
}))}

View File

@@ -129,7 +129,9 @@ export const contractFormSchema = z
contractType: z.enum(["new", "renewal"], "Select a contract type."),
previousContractRef: z.string().default(""),
serviceTypeId: z.string("Select a service type."),
serviceTypeId: z
.string("Select a service type.")
.min(1, "Select a service type."),
paymentCurrency: z.enum(PAYMENT_CURRENCIES, "Select a payment currency."),
firstMile: z
@@ -220,6 +222,14 @@ export const contractFormSchema = z
},
)
.superRefine((data, ctx) => {
// Intercity (domestic) contracts are priced and invoiced in ETB only.
if (data.operationType === "intercity" && data.paymentCurrency !== "ETB") {
ctx.addIssue({
code: "custom",
path: ["paymentCurrency"],
message: "Intercity contracts are priced in ETB.",
});
}
if (data.cargoType === "container") {
// Container scope: at least one enabled size.
if (data.enabledContainerSizes.length === 0) {
@@ -252,29 +262,10 @@ export const contractFormSchema = z
});
}
}
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
// Cargo & Route step until the customer enters a quantity.
if (data.contractKind === "general_contract") {
if (data.cargoType === "container") {
for (const size of data.enabledContainerSizes) {
if (!(data.containerSizeCaps[size] > 0)) {
ctx.addIssue({
code: "custom",
path: ["containerSizeCaps", size],
message: `Enter a ${size} quantity greater than 0.`,
});
}
}
}
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
ctx.addIssue({
code: "custom",
path: ["bulkQuantityCap"],
message: "Enter a total quantity greater than 0.",
});
}
}
// GENERAL contracts are uncapped: no quantity cap is collected, so the
// customer can book repeatedly until the contract's validity expires. The
// cap fields default to 0/empty and map to quantityCap = NULL (uncapped) at
// the API. No cap validation is applied.
});
export type ContractFormValues = z.infer<typeof contractFormSchema>;
@@ -286,7 +277,8 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
previousContractRef: "",
serviceTypeId: "",
paymentCurrency: "USD",
// No preselected currency — the customer must choose (intercity forces ETB).
paymentCurrency: undefined,
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
equipmentReturn: "with_return",

View File

@@ -336,6 +336,16 @@ export function Step2ServiceType({
}
}, [operationType, standaloneServices, form]);
// Intercity (domestic) contracts are priced in ETB only — force the currency
// and let the field render just the ETB option. Also clears a stale USD from
// a restored draft or an operation-type switch.
const isIntercity = operationType === "intercity";
useEffect(() => {
if (isIntercity && form.getValues("paymentCurrency") !== "ETB") {
form.setValue("paymentCurrency", "ETB", { shouldValidate: true });
}
}, [isIntercity, form]);
return (
<Stack gap={18}>
<Controller
@@ -353,7 +363,7 @@ export function Step2ServiceType({
/>
<Box maw={420}>
<PaymentCurrencyField control={form.control} />
<PaymentCurrencyField control={form.control} etbOnly={isIntercity} />
</Box>
{showServiceSections && (

View File

@@ -5,7 +5,6 @@ import {
Box,
Group,
MultiSelect,
NumberInput,
Select,
Skeleton,
Stack,
@@ -57,8 +56,6 @@ export function Step3CargoScope({
const cargoType = form.watch("cargoType");
const cargoTypePath = form.watch("cargoTypePath") ?? [];
const parentId = cargoTypePath[0];
const isGeneral = form.watch("contractKind") === "general_contract";
const enabledSizes = form.watch("enabledContainerSizes") ?? [];
// Reset the commodity child only when the parent group really changes.
const prevParentIdRef = useRef<string | undefined>(parentId);
@@ -224,63 +221,9 @@ export function Step3CargoScope({
</Stack>
)}
{/* GENERAL contract quantity cap (draw-down ceiling). */}
{isGeneral && (
<Box>
<StepLabel>Booking quantity cap *</StepLabel>
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
Total quantity bookable across all shipments under this contract.
Customers / GL can book repeatedly until it is reached. Must be
greater than 0.
</Text>
{cargoType === "container" ? (
<Group gap={12} grow align="flex-start">
{enabledSizes.length === 0 ? (
<Text fz={13} c="dimmed">
Select container sizes above to set their caps.
</Text>
) : (
enabledSizes.map((size) => (
<Controller
key={size}
name={`containerSizeCaps.${size}`}
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label={`${size} cap (containers) *`}
placeholder="e.g. 100"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
))
)}
</Group>
) : (
<Controller
name="bulkQuantityCap"
control={form.control}
render={({ field, fieldState }) => (
<NumberInput
label="Total cap (tons / items) *"
placeholder="e.g. 500"
min={0}
value={Number(field.value ?? 0)}
onChange={(v) => field.onChange(Number(v) || 0)}
error={fieldState.error?.message}
radius={10}
styles={fieldStyles}
/>
)}
/>
)}
</Box>
)}
{/* GENERAL contracts are uncapped — no quantity cap is collected. The
customer / GL can book repeatedly against the contract until its
validity expires (backend stores quantityCap = NULL = uncapped). */}
{/* Shared billing flags. */}
<Box>