Merge branch 'dev' into freight/nati-2

Conflict in ClearanceDocumentsPage: this branch migrated the page to the
pill FilterBar, dev added filters to the Select stack it replaced. Kept
the FilterBar and carried dev's additions across as a "Booked by"
(customerKind) FilterDef plus the shipping-line search placeholder; dev's
startOfDayIso/endOfDayIso went away because dateRangeParams already does
that. The Ship icon import is needed by dev's shipping-line customer cell,
which merged cleanly on its own.
This commit is contained in:
Nathnael
2026-08-17 12:43:35 +00:00
117 changed files with 6115 additions and 830 deletions

View File

@@ -1,5 +1,5 @@
import { Package } from "lucide-react";
import { SimpleGrid, Divider, Box, Table, Text, Badge } from "@mantine/core";
import { SimpleGrid, Divider, Box, Group, Table, Text, Badge } from "@mantine/core";
import type { BookingDetail } from "@/types/booking";
import { cargoTonsAndItems } from "@/utils/cargoWeight";
@@ -29,12 +29,37 @@ export function BookingCargoCard({ booking }: BookingCargoCardProps) {
Number(c.hazardousQuantity ?? 0) > 0 || Number(c.reeferQuantity ?? 0) > 0,
);
const isBulk = booking.freightType === "BULK";
// Bulk: the commodity itself (Wheat, Steel…) is the headline. Containers:
// the freight kind, with the shipper's own description alongside.
const cargoHeadline = isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "Bulk cargo")
: "Containers";
const cargoDescription = booking.cargoFreeText?.trim() || null;
return (
<SectionCard icon={Package} title="Cargo specifications" accent="orange">
<Group gap="sm" align="center" mb="md" wrap="wrap">
<Text fw={800} fz={22} lh={1.1}>
{cargoHeadline}
</Text>
<Badge variant="light" color={isBulk ? "orange" : "blue"} radius="sm">
{isBulk ? "Bulk" : "Container"}
</Badge>
{cargoDescription ? (
<Text size="sm" c="dimmed">
{cargoDescription}
</Text>
) : null}
</Group>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<MetricTile
label="Cargo type"
value={booking.cargoType?.label ?? booking.freightType}
label={isBulk ? "Commodity" : "Cargo type"}
value={
isBulk
? (booking.cargoType?.label ?? booking.cargoType?.name ?? "—")
: (cargoDescription ?? booking.freightType)
}
/>
<MetricTile label="Total VGM" value={`${tons} tons`} />
{items != null && <MetricTile label="Items" value={`${items}`} />}

View File

@@ -1,11 +1,30 @@
import { Alert, Badge, Button, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
import { useState } from "react";
import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
NumberInput,
Radio,
Select,
SimpleGrid,
Stack,
Table,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { AlertTriangle, Ban, Download, FileText, RefreshCw, Send, ShieldCheck } from "lucide-react";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import type { EimsInvoiceStatus } from "@/types/eims";
import { eimsService } from "@/services/eims.service";
import { openPdfBlob } from "@/components/warehouses/pdf";
import { EIMS_MODE_OF_PAYMENT, type EimsInvoiceStatus, type EimsModeOfPayment } from "@/types/eims";
import { useToast } from "@/hooks/use-toast";
const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
@@ -14,6 +33,7 @@ const STATUS_COLOR: Record<EimsInvoiceStatus, string> = {
REGISTERED: "edr-green",
FAILED: "red",
UNKNOWN: "orange",
CANCELLED: "gray",
};
const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
@@ -22,6 +42,7 @@ const STATUS_LABEL: Record<EimsInvoiceStatus, string> = {
REGISTERED: "Filed",
FAILED: "Rejected",
UNKNOWN: "Unacknowledged",
CANCELLED: "Cancelled",
};
function Field({ label, value }: { label: string; value?: string | number | null }) {
@@ -37,6 +58,367 @@ function Field({ label, value }: { label: string; value?: string | number | null
);
}
/** Reason codes from the collection docs, e.g. "1" (Duplicate), "6" (Calculation Error). */
const CANCEL_REASON_CODES = [
{ value: "1", label: "1 — Duplicate" },
{ value: "2", label: "2 — Buyer request" },
{ value: "3", label: "3 — Data entry error" },
{ value: "6", label: "6 — Calculation error" },
];
function CancelModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [reasonCode, setReasonCode] = useState<string | null>(null);
const [remark, setRemark] = useState("");
const cancel = useMutation(
api.invoices.eimsCancel.mutationOptions({
onSuccess: () => {
onClose();
toast({ title: "Cancelled with MoR" });
},
onError: (error) => toast({ title: "Could not cancel", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Cancel EIMS registration" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Cancels this invoice&apos;s registered document at MoR. Irreversible an already-cancelled
invoice refuses a second attempt.
</Text>
<Select
label="Reason code"
withAsterisk
data={CANCEL_REASON_CODES}
value={reasonCode}
onChange={setReasonCode}
placeholder="Select a reason"
/>
<Textarea
label="Remark"
placeholder="Optional note"
value={remark}
onChange={(e) => setRemark(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="red"
leftSection={<Ban size={16} />}
loading={cancel.isPending}
disabled={!reasonCode}
onClick={() => cancel.mutate({ id: invoiceId, reasonCode: reasonCode!, remark: remark.trim() || undefined })}
>
Cancel with MoR
</Button>
</Stack>
</Modal>
);
}
function SalesReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [modeOfPayment, setModeOfPayment] = useState<EimsModeOfPayment | null>(null);
const [collectedAmount, setCollectedAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterSalesReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Sales receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="File sales receipt" centered>
<Stack gap="md">
<Select
label="Mode of payment"
withAsterisk
data={EIMS_MODE_OF_PAYMENT.map((v) => ({ value: v, label: v }))}
value={modeOfPayment}
onChange={(v) => setModeOfPayment(v as EimsModeOfPayment)}
placeholder="Select"
/>
<NumberInput
label="Collected amount"
placeholder="Defaults to the invoice's paid amount"
min={0}
decimalScale={2}
value={collectedAmount}
onChange={(v) => setCollectedAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Payment received"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!modeOfPayment}
onClick={() =>
register.mutate({
id: invoiceId,
modeOfPayment: modeOfPayment!,
collectedAmount: collectedAmount === "" ? undefined : collectedAmount,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function WithholdingReceiptModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState("TWHT");
const [preTaxAmount, setPreTaxAmount] = useState<number | "">("");
const [withholdingAmount, setWithholdingAmount] = useState<number | "">("");
const [reason, setReason] = useState("");
const register = useMutation(
api.invoices.eimsRegisterWithholdingReceipt.mutationOptions({
onSuccess: (receipt) => {
onClose();
toast({ title: "Withholding receipt filed", description: `RRN ${receipt.rrn ?? "—"}` });
},
onError: (error) => toast({ title: "Could not file receipt", description: error.message, variant: "destructive" }),
}),
);
const valid = preTaxAmount !== "" && withholdingAmount !== "";
return (
<Modal opened={opened} onClose={onClose} title="File withholding receipt" centered>
<Stack gap="md">
<TextInput label="Type" withAsterisk value={type} onChange={(e) => setType(e.currentTarget.value)} />
<NumberInput
label="Pre-tax amount"
withAsterisk
min={0}
decimalScale={2}
value={preTaxAmount}
onChange={(v) => setPreTaxAmount(v === "" ? "" : Number(v))}
/>
<NumberInput
label="Withholding amount"
withAsterisk
min={0}
decimalScale={2}
value={withholdingAmount}
onChange={(v) => setWithholdingAmount(v === "" ? "" : Number(v))}
/>
<TextInput
label="Reason"
placeholder='Defaults to "Withholding"'
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
/>
<Button
color="edr-green"
leftSection={<Send size={16} />}
loading={register.isPending}
disabled={!valid}
onClick={() =>
register.mutate({
id: invoiceId,
type,
preTaxAmount: preTaxAmount as number,
withholdingAmount: withholdingAmount as number,
reason: reason.trim() || undefined,
})
}
>
File with MoR
</Button>
</Stack>
</Modal>
);
}
function MemoModal({
invoiceId,
opened,
onClose,
}: {
invoiceId: string;
opened: boolean;
onClose: () => void;
}) {
const { toast } = useToast();
const [type, setType] = useState<"CRE" | "DEB">("CRE");
const [reason, setReason] = useState("");
const issue = useMutation(
api.invoices.issueMemo.mutationOptions({
onSuccess: (memo) => {
onClose();
toast({ title: "Memo issued", description: `${memo.invoiceNumber} — file it with MoR separately` });
},
onError: (error) => toast({ title: "Could not issue memo", description: error.message, variant: "destructive" }),
}),
);
return (
<Modal opened={opened} onClose={onClose} title="Issue credit/debit memo" centered>
<Stack gap="md">
<Text size="sm" c="dimmed">
Creates a new invoice linked to this one, with every line copied verbatim. Filing it with
MoR is a separate step it does not happen automatically here.
</Text>
<Radio.Group value={type} onChange={(v) => setType(v as "CRE" | "DEB")} label="Type">
<Stack gap="xs" mt="xs">
<Radio value="CRE" label="Credit memo" description="Reduces what the buyer owes; created settled." />
<Radio value="DEB" label="Debit memo" description="An additional charge; created as a new open invoice." />
</Stack>
</Radio.Group>
<Textarea
label="Reason"
withAsterisk
placeholder="Why this memo is being issued"
value={reason}
onChange={(e) => setReason(e.currentTarget.value)}
autosize
minRows={2}
/>
<Button
color="edr-green"
loading={issue.isPending}
disabled={!reason.trim()}
onClick={() => issue.mutate({ id: invoiceId, type, reason: reason.trim() })}
>
Issue memo
</Button>
</Stack>
</Modal>
);
}
function ReceiptsSection({ invoiceId, canFile }: { invoiceId: string; canFile: boolean }) {
const { toast } = useToast();
const { data: receipts } = useQuery(api.invoices.eimsReceipts.queryOptions({ input: { id: invoiceId } }));
const [salesOpen, setSalesOpen] = useState(false);
const [withholdingOpen, setWithholdingOpen] = useState(false);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const download = async (receiptId: string, receiptNumber: string) => {
setDownloadingId(receiptId);
try {
const { data } = await eimsService.downloadReceiptDocument(invoiceId, receiptId);
openPdfBlob(data, `${receiptNumber}.pdf`);
} catch (error) {
toast({
title: "Could not download receipt",
description: error instanceof Error ? error.message : undefined,
variant: "destructive",
});
} finally {
setDownloadingId(null);
}
};
return (
<Stack gap="sm">
<Group justify="space-between">
<Text fw={600} size="sm" c="edr-text">
Receipts
</Text>
{canFile && (
<Group gap="xs">
<Button size="xs" variant="light" onClick={() => setSalesOpen(true)}>
File sales receipt
</Button>
<Button size="xs" variant="light" onClick={() => setWithholdingOpen(true)}>
File withholding receipt
</Button>
</Group>
)}
</Group>
{receipts && receipts.length > 0 ? (
<Table striped withTableBorder={false} verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Kind</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>RRN</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{receipts.map((r) => (
<Table.Tr key={r.id}>
<Table.Td>{r.kind}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLOR[r.status] ?? "gray"} variant="light" size="sm">
{STATUS_LABEL[r.status] ?? r.status}
</Badge>
</Table.Td>
<Table.Td style={{ fontFamily: "monospace" }}>{r.rrn ?? "—"}</Table.Td>
<Table.Td>
{r.status === "REGISTERED" && (
<Button
size="xs"
variant="subtle"
leftSection={<Download size={14} />}
loading={downloadingId === r.id}
onClick={() => void download(r.id, r.receiptNumber)}
>
PDF
</Button>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
) : (
<Text size="sm" c="dimmed">
No receipts filed yet.
</Text>
)}
<SalesReceiptModal invoiceId={invoiceId} opened={salesOpen} onClose={() => setSalesOpen(false)} />
<WithholdingReceiptModal invoiceId={invoiceId} opened={withholdingOpen} onClose={() => setWithholdingOpen(false)} />
</Stack>
);
}
/**
* MoR EIMS filing state for one invoice, with the manual actions.
*
@@ -48,6 +430,12 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
const { user } = useAuth();
const { toast } = useToast();
const canFile = hasPermission(user, FREIGHT_PERMS.invoices.eimsRegister);
const canCancel = hasPermission(user, FREIGHT_PERMS.invoices.eimsCancel);
const canFileReceipt = hasPermission(user, FREIGHT_PERMS.invoices.eimsReceiptRegister);
const canIssueMemo = hasPermission(user, FREIGHT_PERMS.invoices.memoIssue);
const [cancelOpen, setCancelOpen] = useState(false);
const [memoOpen, setMemoOpen] = useState(false);
const { data: eims, isLoading } = useQuery(
api.invoices.eimsStatus.queryOptions({ input: { id: invoiceId }, enabled: Boolean(invoiceId) }),
@@ -118,39 +506,78 @@ export function EimsFilingCard({ invoiceId }: { invoiceId: string }) {
</Alert>
)}
{canFile && (
<Group gap="sm">
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</Group>
{status === "CANCELLED" && (
<Alert color="gray" icon={<Ban size={16} />} title="Cancelled with MoR">
{eims.eimsCancellationDate ? `Confirmed ${eims.eimsCancellationDate}. ` : ""}
{eims.eimsCancellationRemark}
</Alert>
)}
<Group gap="sm">
{canFile && (
<>
{/* UNKNOWN is never re-filed from here: resubmitting risks a duplicate registration. */}
{status !== "REGISTERED" && status !== "UNKNOWN" && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
radius="md"
loading={register.isPending}
disabled={busy}
leftSection={status === "FAILED" ? <RefreshCw size={14} /> : <Send size={14} />}
onClick={() => register.mutate({ id: invoiceId })}
>
{status === "FAILED" ? "File again" : "File with MoR"}
</Button>
)}
{eims.eimsIrn && (
<Button
size="xs"
variant="light"
radius="md"
loading={verify.isPending}
disabled={busy}
leftSection={<ShieldCheck size={14} />}
onClick={() => verify.mutate({ id: invoiceId })}
>
Verify with MoR
</Button>
)}
</>
)}
{canCancel && eims.eimsIrn && status !== "CANCELLED" && (
<Button
size="xs"
variant="light"
color="red"
radius="md"
leftSection={<Ban size={14} />}
onClick={() => setCancelOpen(true)}
>
Cancel with MoR
</Button>
)}
{canIssueMemo && status === "REGISTERED" && (
<Button
size="xs"
variant="light"
radius="md"
leftSection={<FileText size={14} />}
onClick={() => setMemoOpen(true)}
>
Issue credit/debit memo
</Button>
)}
</Group>
{eims.eimsIrn && <ReceiptsSection invoiceId={invoiceId} canFile={canFileReceipt} />}
</Stack>
<CancelModal invoiceId={invoiceId} opened={cancelOpen} onClose={() => setCancelOpen(false)} />
<MemoModal invoiceId={invoiceId} opened={memoOpen} onClose={() => setMemoOpen(false)} />
</Card>
);
}

View File

@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
next.containerTypeId = "";
next.cargoTypeId = "";
}
// Cargo kind (customs / lashing) decides both the container-type scope
// Cargo kind (customs / cancellation) decides both the container-type scope
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
if (name === "cargoKind") {
next.containerTypeId = "";

View File

@@ -0,0 +1,103 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useEffect, useState } from "react";
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
*/
export function CheckpointTimeModal({
opened,
onClose,
title,
icon,
description,
initialOccurredAt,
initialNote,
submitLabel,
submitColor = "edr-green",
loading,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
title: string;
icon?: React.ReactNode;
description?: string;
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap={8}>
{icon}
<Text fw={700}>{title}</Text>
</Group>
}
>
<Stack gap="md">
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<DateTimePicker
label="Time"
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Textarea
label="Note"
placeholder="Optional"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
maxRows={4}
maxLength={500}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
color={submitColor}
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,423 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
physicalWagonId: string | null;
label: string;
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
* Loads move by click: pick a load, then click a wagon that is free on that
* load's legs (move) or another load (swap). Same API as the consist strip.
*/
export function LegLoadBoardPanel({
schedule,
onChanged,
}: {
schedule: TrainScheduleDetail;
onChanged?: () => void;
}) {
const { toast } = useToast();
const stops: Stop[] = schedule.stops ?? [];
const legs = useMemo(
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
[stops],
);
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
const spanOf = (slot: Slot): Span => {
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
const to = slot.alightYardId
? stops.findIndex((s) => s.yardId === slot.alightYardId)
: stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const rows: WagonRow[] = useMemo(() => {
const byKey = new Map<string, WagonRow>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
let row = byKey.get(key);
if (!row) {
row = {
key,
physicalWagonId: slot.physicalWagonId ?? null,
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
position: slot.position ?? slot.sequenceNo,
typeCode: slot.wagonType?.code ?? null,
capacityTons: slot.capacityTons ?? 0,
slots: [],
};
byKey.set(key, row);
}
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule.trainSet?.wagons, stops]);
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
null,
);
useEffect(() => {
if (!picked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [picked]);
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
const doMove = async (targetWagonId: string, swap: boolean) => {
if (!picked || moveMutation.isPending) return;
try {
await moveMutation.mutateAsync({
scheduleId: schedule.id,
wagonId: picked.slotId,
targetWagonId,
});
toast({ title: swap ? "Loads swapped" : "Load moved" });
setPicked(null);
onChanged?.();
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check wagon type, payload and leg."),
variant: "destructive",
});
}
};
if (stops.length < 2) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
This schedule has no corridor stops yet the leg board needs a route with at least
two stops.
</Alert>
);
}
if (!rows.length) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
No wagons on this train yet.
</Alert>
);
}
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700} size="sm">
Loads per wagon per leg
</Text>
<Text size="xs" c="dimmed">
One row per physical wagon, one column per leg. A wagon reused on different legs
shows one load per leg.{" "}
{canRearrange
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
: "Read-only — the train has departed."}
</Text>
</Stack>
<Group gap="xs">
{sharedRows > 0 ? (
<Badge variant="light" color="violet" radius="sm">
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
</Badge>
) : null}
{picked ? (
<Button
size="xs"
variant="default"
leftSection={<X size={14} />}
onClick={() => setPicked(null)}
>
Cancel move (Esc)
</Button>
) : null}
</Group>
</Group>
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
Wagon
</Table.Th>
{legs.map((leg) => (
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
<Group gap={4} wrap="nowrap">
<Text size="xs" fw={700} truncate>
{leg.from.label}
</Text>
<MoveRight size={12} />
<Text size="xs" fw={700} truncate>
{leg.to.label}
</Text>
</Group>
</Table.Th>
))}
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
const rowFreeForPicked =
!!picked &&
!isPickedRow &&
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
// Where a "move here" lands: an existing empty slot on those legs,
// else the physical wagon itself (the API mints the slot).
const emptyTargetSlot = picked
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
: undefined;
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
// Lay slots into leg columns; uncovered legs render as empty cells.
const cells: React.ReactNode[] = [];
let col = 0;
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
// Empty cell = uncovered leg (target: the physical wagon) or an
// empty slot (target: that slot). Both take the picked load when
// the row is free on its legs.
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
!!picked && overlaps([from, to], picked.span);
return (
<Table.Td
key={`e-${from}`}
colSpan={Math.max(1, to - from)}
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
style={{
cursor: droppable ? "pointer" : "default",
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
outlineOffset: -3,
borderRadius: 6,
}}
>
{droppable ? (
<Text size="xs" c="teal.7" fw={600} ta="center">
Move here
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
</Text>
)}
</Table.Td>
);
};
for (const s of sorted) {
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
if (!s.loaded) {
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
col = Math.max(col, s.span[1]);
continue;
}
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange
? undefined
: s.loaded && !picked
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
: swappable
? () => void doMove(s.slot.id, true)
: isPicked
? () => setPicked(null)
: undefined
}
style={{
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
padding: 4,
}}
>
{s.loaded ? (
<Paper
radius="sm"
px={8}
py={6}
style={{
background: bulk
? "var(--mantine-color-orange-0)"
: "var(--mantine-color-cyan-0)",
borderLeft: `4px solid ${
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
}`,
outline: isPicked
? "2px solid var(--mantine-color-edr-green-6)"
: swappable
? "1px dashed var(--mantine-color-orange-6)"
: undefined,
outlineOffset: 1,
}}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
<Text size="xs" fw={700} truncate>
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{round1(
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
)}{" "}
t
</Text>
</Group>
<Group gap={4} mt={2} wrap="wrap">
{bulk
? allocs.map((a) =>
a.bulkLoad ? (
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
</Badge>
) : null,
)
: containers.map((c) => (
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
{c.containerNumber ?? "no number"}
</Badge>
))}
{swappable ? (
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
swap
</Badge>
) : null}
</Group>
</Paper>
) : (
<Text size="xs" c="dimmed" ta="center">
empty
</Text>
)}
</Table.Td>,
);
col = Math.max(col, s.span[1]);
}
if (col < legs.length) cells.push(emptyCell(col, legs.length));
return (
<Table.Tr
key={row.key}
style={{
background: isPickedRow
? "var(--mantine-color-green-0)"
: rowFreeForPicked
? undefined
: picked
? "var(--mantine-color-gray-0)"
: undefined,
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
}}
>
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
<Group gap={6} wrap="nowrap">
<Badge variant="outline" color="gray" radius="sm" size="sm">
#{row.position}
</Badge>
<Stack gap={0}>
<Text size="sm" fw={700}>
{row.label}
</Text>
<Text size="xs" c="dimmed">
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
</Text>
</Stack>
{row.slots.filter((s) => s.loaded).length > 1 ? (
<Tooltip label="This wagon carries different loads on different legs">
<Badge size="xs" color="violet" variant="light" radius="sm">
shared
</Badge>
</Tooltip>
) : null}
</Group>
</Table.Td>
{cells.map((c, i) => (
<Fragment key={i}>{c}</Fragment>
))}
<Table.Td>
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
{round1(cargoTons)} / {round1(row.capacityTons)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -12,6 +12,7 @@ import {
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
CheckCircle2,
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
}) {
const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery(
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
const doLogPass = () => {
if (!station) return;
recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } },
{
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{
onSuccess: () => {
setJustLogged(true);
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
</>
)}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react";
import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
canLog: boolean;
loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
}
const COLUMN_WIDTH = 150;
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
canLog,
loggingSeq,
onLogCheckpoint,
onEditCheckpoint,
}: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1;
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
{/* checkpoint time or action */}
{checkpoint ? (
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
<Stack gap={2} align="center">
<Text size="10px" c="dimmed" ta="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius="md"
variant="subtle"
color="gray"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit time
</Button>
) : null}
</Stack>
) : isNext ? (
<Button
size="compact-xs"