mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 08:25:43 +00:00
changes
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -19,6 +19,7 @@ export const BOOKING_STATUSES = [
|
||||
"PAYMENT_VERIFICATION_IN_PROGRESS",
|
||||
"PAID",
|
||||
"IN_TRANSIT",
|
||||
"ARRIVED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
"CANCELLED",
|
||||
|
||||
@@ -118,6 +118,7 @@ export type CustomerBookingStatus =
|
||||
| "APPROVED"
|
||||
| "PAID"
|
||||
| "IN_TRANSIT"
|
||||
| "ARRIVED"
|
||||
| "COMPLETED"
|
||||
| "REJECTED"
|
||||
| "CANCELLED";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user