Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-25 13:07:09 +00:00
41 changed files with 3356 additions and 137 deletions

View File

@@ -88,6 +88,7 @@ import {
import {
ConsolidationPartnerPanel,
emptyPartnerLine,
emptyPartnerUnit,
} from "./gl-booking-form/ConsolidationPartnerPanel";
import { ConsolidationPartnerPicker } from "./gl-booking-form/ConsolidationPartnerPicker";
@@ -250,6 +251,17 @@ export default function GlCreateBookingForm() {
enabled: Boolean(requestId),
});
// The shipment request is the customer's order: container sizes/quantities
// and the billing currency are the customer's choices and stay read-only —
// GL enters only per-unit details (numbers, seals, VGM, handling). The
// server enforces the same on completion.
const requestContainersLocked = Boolean(
bookingRequest?.requestedLines?.containers?.length,
);
const requestBulkLocked =
bookingRequest?.requestedLines?.bulk?.cargoWeightTons != null;
const requestCurrencyLocked = Boolean(bookingRequest?.paymentCurrency);
// The expired booking a Rebook is copying from (its cargo seeds the form).
const { data: copyFromBooking } = useQuery({
queryKey: ["rebook-copy-from", copyFromParam],
@@ -328,6 +340,39 @@ export default function GlCreateBookingForm() {
const [partner, setPartner] = useState<ConsolidationCandidate | null>(null);
const [partnerLines, setPartnerLines] = useState<ContainerLineDraft[]>([]);
const [partnerCargoDescription, setPartnerCargoDescription] = useState("");
// The partner is its own customer: if a shipment request created it, that
// request locks the partner's quantities and billing currency the same way
// this booking's request locks this side (server enforces both halves).
const { data: partnerContractRequests } = useQuery({
queryKey: ["shipment-requests-for-contract", partner?.contractId],
queryFn: () => contractsService.listBookingRequests(partner!.contractId!),
enabled: Boolean(partner?.contractId),
});
const partnerRequest =
(partner &&
partnerContractRequests?.find(
(r) => r.createdBookingId === partner.id,
)) ||
null;
const partnerLocked = Boolean(partnerRequest?.requestedLines?.containers?.length);
// Seed (and lock) the partner's lines from its request once it loads.
useEffect(() => {
const requested = partnerRequest?.requestedLines?.containers;
if (!partner || !requested?.length) return;
setPartnerLines(
requested.map((c) => ({
containerSize: c.containerSize,
quantity: String(Math.max(1, c.quantity)),
hazardousQuantity: "0",
reeferQuantity: "0",
returnQuantity: "0",
units: Array.from({ length: Math.max(1, c.quantity) }, emptyPartnerUnit),
})),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [partner?.id, partnerRequest?.id]);
const seededRef = useRef(false);
const returnSeededRef = useRef(false);
@@ -490,6 +535,14 @@ export default function GlCreateBookingForm() {
if (bookingRequest.contractRouteId)
setContractRouteId(bookingRequest.contractRouteId);
if (bookingRequest.notes) setNotes(bookingRequest.notes);
// Currency is the customer's choice on the request — seed it here; the
// selector below is disabled while the request specifies one.
if (
bookingRequest.paymentCurrency === "USD" ||
bookingRequest.paymentCurrency === "ETB"
) {
setPaymentCurrency(bookingRequest.paymentCurrency);
}
}, [bookingRequest, prefilled]);
// Rebook seed: copy the source booking's container lines once. (Bulk weight /
@@ -1114,7 +1167,13 @@ export default function GlCreateBookingForm() {
if (!partner || !consolidationActive) return null;
const payload: Freight.CreateBookingUnderContractDto = {
paymentCurrency: effectiveCurrency,
// The partner's customer chose its own currency on its shipment request;
// only a partner without a request falls back to this booking's currency.
paymentCurrency:
partnerRequest?.paymentCurrency === "USD" ||
partnerRequest?.paymentCurrency === "ETB"
? partnerRequest.paymentCurrency
: effectiveCurrency,
...(scheduledDate
? { scheduledDate: new Date(scheduledDate).toISOString() }
: {}),
@@ -1663,6 +1722,12 @@ export default function GlCreateBookingForm() {
label="Quantity *"
min={0}
value={line.quantity}
disabled={requestContainersLocked}
description={
requestContainersLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
error={
showErrors
? (lineErrors[lineIdx]?.quantity ??
@@ -1924,6 +1989,7 @@ export default function GlCreateBookingForm() {
showReefer={Boolean(contract.isReefer)}
showErrors={showErrors}
error={partnerError}
lockQuantities={partnerLocked}
/>
</>
) : null}
@@ -1946,6 +2012,12 @@ export default function GlCreateBookingForm() {
placeholder="e.g. 1200"
min={0}
step={0.01}
disabled={requestBulkLocked}
description={
requestBulkLocked
? "Requested by the customer — quantity cannot be changed."
: undefined
}
value={bulk.cargoWeightTons}
error={
showErrors && bulkUom === "PER_TON"
@@ -2147,14 +2219,16 @@ export default function GlCreateBookingForm() {
Billing currency
</Text>
<Text size="xs" c="dimmed" mb={8}>
{isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
{requestCurrencyLocked
? "The customer chose the billing currency on the shipment request — it cannot be changed."
: isImport
? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online."
: "Shipments are invoiced in ETB."}
</Text>
<CurrencySelector
value={isImport ? paymentCurrency : "ETB"}
onChange={setPaymentCurrency}
disabled={!isImport}
disabled={!isImport || requestCurrencyLocked}
allowUsd={isImport}
error={currencyError}
/>

View File

@@ -86,6 +86,11 @@ interface Props {
/** Surface field errors only after the operator tried to continue. */
showErrors: boolean;
error?: string;
/**
* The partner's shipment request fixed its sizes/quantities — the quantity
* fields render read-only and GL enters only per-unit details.
*/
lockQuantities?: boolean;
}
export function ConsolidationPartnerPanel({
@@ -97,6 +102,7 @@ export function ConsolidationPartnerPanel({
showReefer,
showErrors,
error,
lockQuantities,
}: Props) {
const patchLine = (index: number, patch: Partial<PartnerLineDraft>) => {
onLinesChange(
@@ -149,6 +155,12 @@ export function ConsolidationPartnerPanel({
label="Quantity *"
min={0}
value={line.quantity}
disabled={lockQuantities}
description={
lockQuantities
? "Requested by the partner's customer — quantity cannot be changed."
: undefined
}
onChange={(e) => patchLine(lineIdx, { quantity: e.currentTarget.value })}
// Sync off the typed value, not the captured `line` — that snapshot
// still holds the pre-edit quantity and would write it back.

View File

@@ -115,6 +115,7 @@ export function LogPassYardWorkModal({
const { toast } = useToast();
const { user } = useAuth();
const canLoad = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.load);
const canLeave = hasFreightPermission(user, FREIGHT_PERMS.trainScheduling.update);
const [justLogged, setJustLogged] = useState(false);
// When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
@@ -134,6 +135,10 @@ export function LogPassYardWorkModal({
api.trainScheduling.recordCheckpoint.mutationOptions(),
);
const load = useMutation(api.trainScheduling.loadScheduleBooking.mutationOptions());
// "Leave behind": the cargo is not on the train — unassign frees its wagons
// and returns the booking to the pool for a later schedule. Reversible (the
// booking can be re-assigned), so no extra confirm step.
const leave = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
const yard = yardWorkQuery.data?.yards.find((y) => y.yardId === station?.yardId);
const boarders: YardWorkBookingRow[] = yard?.toLoad ?? [];
@@ -196,6 +201,28 @@ export function LogPassYardWorkModal({
);
};
const doLeave = (row: YardWorkBookingRow) => {
leave.mutate(
{ id: scheduleId, bookingId: row.id },
{
onSuccess: () => {
toast({
title: `${row.reference ?? "Booking"} left behind`,
description:
"Removed from this train — wagons freed, booking returned to the pool for a later schedule.",
});
void yardWorkQuery.refetch();
},
onError: (err) =>
toast({
title: "Could not leave booking behind",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
const hasWork = boarders.length > 0 || arrivals.length > 0;
return (
@@ -355,30 +382,54 @@ export function LogPassYardWorkModal({
</Table.Td>
<Table.Td>
{!row.loadedAt ? (
<Tooltip
label={
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
<Group gap={6} wrap="nowrap">
<Tooltip
label={
!canLoad
? "You don't have permission to load cargo"
: !logged
? "Log the pass first — the train must be at this yard"
: !row.canLoad
? "Booking is not ready to load (payment pending)"
: "Confirm cargo loaded onto the train"
}
onClick={() => doLoad(row)}
>
Load
</Button>
</Tooltip>
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
disabled={!canLoad || !logged || !row.canLoad}
loading={
load.isPending && load.variables?.bookingId === row.id
}
onClick={() => doLoad(row)}
>
Load
</Button>
</Tooltip>
<Tooltip
label={
row.isGovernment
? "Government bookings cannot be removed from a train"
: !canLeave
? "You don't have permission to remove bookings"
: "Cargo is not on the train — free its wagons and return the booking to the pool"
}
>
<Button
size="compact-xs"
variant="light"
color="red"
disabled={!canLeave || row.isGovernment}
loading={
leave.isPending && leave.variables?.bookingId === row.id
}
onClick={() => doLeave(row)}
>
Leave
</Button>
</Tooltip>
</Group>
) : null}
</Table.Td>
</Table.Tr>

View File

@@ -1,7 +1,9 @@
import { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
Card,
Code,
Group,
@@ -42,6 +44,26 @@ const OUTCOME_OPTIONS = [
{ value: "false", label: "Failed" },
];
/**
* Entity type → detail page for that record. Drives the row's "Go" button;
* types without a detail page (Wagon, Locomotive, …) simply have no button.
*/
const ENTITY_ROUTES: Record<string, (id: string) => string> = {
Booking: (id) => `/dashboard/booking-requests/${id}`,
Contract: (id) => `/dashboard/contract-requests/${id}`,
Schedule: (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
"Train Schedule": (id) => `/dashboard/operations/train-scheduling-v2/${id}`,
Train: (id) => `/dashboard/trains/${id}`,
"Train Build": (id) => `/dashboard/trains/${id}`,
"EIMS Invoice": (id) => `/dashboard/invoices/${id}`,
Payment: (id) => `/dashboard/invoices/${id}`,
Vehicle: (id) => `/dashboard/vehicles/${id}`,
Company: (id) => `/dashboard/customers/${id}`,
};
const entityRoute = (log: AuditLog): string | null =>
log.resourceId ? (ENTITY_ROUTES[log.type]?.(log.resourceId) ?? null) : null;
/** `YYYY-MM-DD` → inclusive ISO bounds, so a single day covers its full range. */
const startOfDay = (date: string) => `${date}T00:00:00.000Z`;
const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
@@ -49,13 +71,20 @@ const endOfDay = (date: string) => `${date}T23:59:59.999Z`;
const formatTimestamp = (value: string) => new Date(value).toLocaleString();
const AuditLogsPage = () => {
const navigate = useNavigate();
// Entity pages deep-link here as /dashboard/audit-logs?type=Booking&resourceId=<id>
// to show one record's full history with the filters already applied.
const [searchParams] = useSearchParams();
// Server-side filters. Unlike most freight lists (which filter an
// already-fetched array via useListControls), audit_logs is append-only and
// grows without bound, so filtering and paging both happen in the API.
const [search, setSearch] = useState("");
const [search, setSearch] = useState(searchParams.get("q") ?? "");
const [dateFrom, setDateFrom] = useState<string | null>(null);
const [dateTo, setDateTo] = useState<string | null>(null);
const [type, setType] = useState<string | null>(null);
const [type, setType] = useState<string | null>(searchParams.get("type"));
const [resourceId] = useState<string | null>(searchParams.get("resourceId"));
const [action, setAction] = useState<string | null>(null);
const [method, setMethod] = useState<string | null>(null);
const [outcome, setOutcome] = useState<string | null>(null);
const [selected, setSelected] = useState<AuditLog | null>(null);
@@ -69,13 +98,16 @@ const AuditLogsPage = () => {
type: type ?? undefined,
method: (method as AuditMethod | null) ?? undefined,
isSuccess: outcome === null ? undefined : outcome === "true",
// The API filters by record id; the search box is the natural place to
// paste one when tracing what happened to a specific contract/booking.
resourceId: search.trim() || undefined,
// Free-text: matches reference (booking/schedule/train number), record
// id, staff name and action title server-side.
q: search.trim() || undefined,
title: action ?? undefined,
// Set only via deep link from an entity page's "History" button.
resourceId: resourceId ?? undefined,
from: dateFrom ? startOfDay(dateFrom) : undefined,
to: dateTo ? endOfDay(dateTo) : undefined,
}),
[pagination, type, method, outcome, search, dateFrom, dateTo],
[pagination, type, method, outcome, search, action, resourceId, dateFrom, dateTo],
);
const logsQuery = useQuery({
@@ -88,12 +120,17 @@ const AuditLogsPage = () => {
queryFn: () => auditLogsService.types(),
});
const actionsQuery = useQuery({
queryKey: ["audit-logs", "actions"],
queryFn: () => auditLogsService.actions(),
});
const rows = logsQuery.data?.items ?? [];
const totalCount = logsQuery.data?.meta.total ?? 0;
const pageCount = logsQuery.data?.meta.totalPages ?? 0;
const hasFilters = Boolean(
search || dateFrom || dateTo || type || method || outcome,
search || dateFrom || dateTo || type || action || method || outcome,
);
const resetFilters = () => {
@@ -101,6 +138,7 @@ const AuditLogsPage = () => {
setDateFrom(null);
setDateTo(null);
setType(null);
setAction(null);
setMethod(null);
setOutcome(null);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -135,7 +173,7 @@ const AuditLogsPage = () => {
<ListControls
search={search}
onSearchChange={onFilterChange(setSearch)}
searchPlaceholder="Filter by record id…"
searchPlaceholder="Booking / schedule / train number, staff name, action…"
dateFrom={dateFrom}
onDateFromChange={onFilterChange(setDateFrom)}
dateTo={dateTo}
@@ -154,6 +192,16 @@ const AuditLogsPage = () => {
searchable
w={200}
/>
<Select
label="Action"
placeholder="All actions"
data={actionsQuery.data ?? []}
value={action}
onChange={onFilterChange(setAction)}
clearable
searchable
w={260}
/>
<Select
label="Method"
placeholder="All methods"
@@ -193,10 +241,12 @@ const AuditLogsPage = () => {
<Table.Tr>
<Table.Th>Action</Table.Th>
<Table.Th>Entity</Table.Th>
<Table.Th>Reference</Table.Th>
<Table.Th>Method</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Outcome</Table.Th>
<Table.Th>When</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -214,6 +264,11 @@ const AuditLogsPage = () => {
<Table.Td>
<Badge variant="light">{log.type}</Badge>
</Table.Td>
<Table.Td>
<Text size="sm" ff="monospace">
{log.reference || "—"}
</Text>
</Table.Td>
<Table.Td>
<Badge color={METHOD_COLORS[log.method]} variant="light">
{log.method}
@@ -250,6 +305,21 @@ const AuditLogsPage = () => {
<Table.Td>
<Text size="sm">{formatTimestamp(log.createdAt)}</Text>
</Table.Td>
<Table.Td>
{entityRoute(log) ? (
<Button
size="compact-xs"
variant="light"
onClick={(event) => {
// The row itself opens the detail modal.
event.stopPropagation();
navigate(entityRoute(log)!);
}}
>
Go
</Button>
) : null}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
@@ -277,6 +347,7 @@ const AuditLogsPage = () => {
<Stack gap="sm">
<DetailRow label="Action" value={selected.title} />
<DetailRow label="Entity" value={selected.type} />
<DetailRow label="Reference" value={selected.reference || null} />
<DetailRow label="Record id" value={selected.resourceId} />
<DetailRow label="Method" value={selected.method} />
<DetailRow label="URL" value={selected.url} />

View File

@@ -74,6 +74,23 @@ interface WagonCancellation {
};
}
interface RebookPartnerCandidate {
id: string;
reference: string;
companyName: string | null;
status: string;
scheduledDate: string | null;
ft20Quantity: number;
}
/** Odd 20ft in the credit ⇒ the rebooked booking shares a wagon and GL must pick the partner. */
const hasOddFt20 = (r: WagonCancellation): boolean =>
Object.entries(r.cancelledQuantities?.bySize ?? {})
.filter(([size]) => parseInt(size, 10) === 20)
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
2 ===
1;
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
@@ -147,10 +164,12 @@ export default function WagonCancellationsPage() {
);
const [rebooking, setRebooking] = useState<WagonCancellation | null>(null);
const [rebookDate, setRebookDate] = useState<Date | null>(null);
const [rebookPartnerId, setRebookPartnerId] = useState<string | null>(null);
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[]>([]);
const openRebook = (r: WagonCancellation) => {
setRebooking(r);
setRebookDate(null);
setRebookPartnerId(null);
setRebookDrafts(
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
@@ -179,9 +198,30 @@ export default function WagonCancellationsPage() {
api.post(`/bookings/wagon-cancellations/${rebooking!.id}/rebook`, {
scheduledDate: toDayString(rebookDate!),
...(rebookDrafts.length ? { containers: rebookContainersPayload() } : {}),
...(rebookPartnerId ? { partnerBookingId: rebookPartnerId } : {}),
}),
});
// Odd-20ft credit: the rebooked booking shares a wagon again, so GL must pick
// the odd partner booking riding the chosen day. It ships once that partner pays.
const rebookNeedsPartner = rebooking ? hasOddFt20(rebooking) : false;
const rebookPartners = useQuery({
queryKey: [
"wagon-cancellations",
rebooking?.id,
"rebook-partners",
rebookDate ? toDayString(rebookDate) : null,
],
enabled: Boolean(rebooking && rebookNeedsPartner && rebookDate),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${rebooking!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(rebookDate!) } },
);
return res.data;
},
});
const resetPage = () =>
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
@@ -301,11 +341,12 @@ export default function WagonCancellationsPage() {
const r = row.original;
const showVoid = r.status === "FEE_PENDING" && canVoid;
// Customs credits are GL's to rebook; non-customs ones the customer
// rebooks from the portal.
// rebooks from the portal — EXCEPT odd-20ft credits: those must be
// re-paired with a partner booking, which only GL can pick.
const showRebook =
r.status === "CREDIT_AVAILABLE" &&
canRebook &&
Boolean(r.booking?.customsClearingEnabled) &&
(Boolean(r.booking?.customsClearingEnabled) || hasOddFt20(r)) &&
Number(r.creditAmount) > 0;
if (!showVoid && !showRebook) return null;
return (
@@ -495,9 +536,43 @@ export default function WagonCancellationsPage() {
label="Shipment day"
placeholder="Pick the day"
value={rebookDate}
onChange={(v) => setRebookDate(v ? new Date(v) : null)}
onChange={(v) => {
setRebookDate(v ? new Date(v) : null);
setRebookPartnerId(null);
}}
radius="md"
/>
{rebookNeedsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
placeholder={
!rebookDate
? "Pick the day first"
: rebookPartners.isLoading
? "Loading…"
: "Pick the partner booking"
}
data={(rebookPartners.data ?? []).map((c) => ({
value: c.id,
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={rebookPartnerId}
onChange={setRebookPartnerId}
disabled={!rebookDate}
searchable
radius="md"
/>
)}
{rebookNeedsPartner &&
rebookDate &&
!rebookPartners.isLoading &&
(rebookPartners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait
for a partner booking.
</Text>
)}
{rebookDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" c="dimmed">
@@ -569,7 +644,9 @@ export default function WagonCancellationsPage() {
<Button
color="green"
radius="md"
disabled={!rebookDate}
disabled={
!rebookDate || (rebookNeedsPartner && !rebookPartnerId)
}
loading={rebook.isPending}
onClick={async () => {
try {

View File

@@ -133,8 +133,14 @@ export default function TrainScheduleV2DetailPage() {
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Loading is manual: dispatch decides the fate of every unloaded origin
// boarder — checked = loaded and departs, unchecked = left behind (wagon
// freed, booking back to the pool). Default unchecked; government bookings
// cannot be removed from a train so they are forced on.
const [dispatchLoadedIds, setDispatchLoadedIds] = useState<Set<string>>(new Set());
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchLoadedIds(new Set());
setDispatchConfirmOpen(true);
};
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
@@ -465,6 +471,25 @@ export default function TrainScheduleV2DetailPage() {
// per yard from the track page's log-pass flow. Everything below is advisory.
const hasDispatchWarnings =
unassignedCount > 0 || unloadedCount > 0 || intercityNotLoadedCount > 0;
// Unloaded boarders at the TRAIN's origin — the dispatch dialog's manual
// load/leave list. Mirrors the API's unloadedOriginBoarderIds predicate
// (plus government, which is shown but forced-loaded).
const originYardId = schedule.originStation?.id;
const pendingOriginBoarders = dispatchBookings.filter(
(b) =>
Boolean(b.originYardId) &&
b.originYardId === originYardId &&
!b.loadedAt &&
(b.loadingStatus ?? "UNLOADED") !== "LOADED" &&
(b.isGovernment
? b.status === "APPROVED" || b.status === "PAID"
: b.status === "PAID" ||
// Shipping-line bookings ride from accept on the credit ledger.
(Boolean(b.shippingLineCompanyId) && b.status === "FULLY_EXECUTED")),
);
const dispatchLeftCount = pendingOriginBoarders.filter(
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
).length;
const finalizeStep = hasContainerStep ? 3 : 2;
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
@@ -516,7 +541,12 @@ export default function TrainScheduleV2DetailPage() {
try {
await dispatch.mutateAsync({
id: scheduleId,
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
payload: {
...(dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {}),
loadedBookingIds: pendingOriginBoarders
.filter((b) => b.isGovernment || dispatchLoadedIds.has(b.id))
.map((b) => b.id),
},
});
await openMarshallingDocument({
title: "Train dispatched",
@@ -1524,6 +1554,48 @@ export default function TrainScheduleV2DetailPage() {
radius="md"
/>
{pendingOriginBoarders.length > 0 ? (
<Stack gap={6}>
<Text size="sm" fw={700}>
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"}
tick what was loaded
</Text>
<Text size="xs" c="dimmed">
Unticked bookings are left behind: removed from this train, their
wagons freed, and the booking returned to the pool for a later
schedule. The customer is notified.
</Text>
<Stack gap={6} mah={220} style={{ overflowY: "auto" }}>
{pendingOriginBoarders.map((b) => (
<Checkbox
key={b.id}
size="sm"
checked={b.isGovernment || dispatchLoadedIds.has(b.id)}
disabled={b.isGovernment}
onChange={(e) => {
const next = new Set(dispatchLoadedIds);
if (e.currentTarget.checked) next.add(b.id);
else next.delete(b.id);
setDispatchLoadedIds(next);
}}
label={
<Text size="sm" span>
{b.reference ?? b.id.slice(0, 8)} {b.customer ?? "Unknown customer"}
{b.isGovernment ? " (government — always rides)" : ""}
</Text>
}
/>
))}
</Stack>
{dispatchLeftCount > 0 ? (
<Text size="xs" c="orange.7" fw={600}>
{dispatchLeftCount} booking{dispatchLeftCount === 1 ? "" : "s"} will
be left behind and returned to the booking pool.
</Text>
) : null}
</Stack>
) : null}
{hasDispatchWarnings ? (
<Alert
color="orange"

View File

@@ -36,6 +36,8 @@ export interface AuditLog {
userName: string | null;
userRole: string | null;
resourceId: string | null;
/** Human identifier of the record (booking reference, train number); '' when unknown. */
reference: string;
/** Sanitized request body; files appear as `__file` descriptors. */
request: Record<string, unknown> | null;
ipAddress: string | null;
@@ -52,6 +54,14 @@ export interface AuditLogQuery {
userId?: string;
method?: AuditMethod;
resourceId?: string;
/** Case-insensitive prefix match on the human identifier. */
reference?: string;
/** Staff name, substring match. */
userName?: string;
/** Action title, substring match. */
title?: string;
/** Free text across reference, record id, staff name and action title. */
q?: string;
/** Omit for "any outcome". */
isSuccess?: boolean;
/** Inclusive ISO 8601 bounds. */
@@ -72,6 +82,10 @@ function toParams(query: AuditLogQuery): Record<string, string | number> {
if (query.userId) params.userId = query.userId;
if (query.method) params.method = query.method;
if (query.resourceId) params.resourceId = query.resourceId;
if (query.reference) params.reference = query.reference;
if (query.userName) params.userName = query.userName;
if (query.title) params.title = query.title;
if (query.q) params.q = query.q;
if (query.isSuccess !== undefined) params.isSuccess = String(query.isSuccess);
if (query.from) params.from = query.from;
if (query.to) params.to = query.to;
@@ -94,4 +108,10 @@ export const auditLogsService = {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/types`);
return unwrap(response.data);
},
/** Distinct action titles present, for the action filter dropdown. */
actions: async (): Promise<string[]> => {
const response = await client.get<ApiResponse<string[]>>(`${BASE}/actions`);
return unwrap(response.data);
},
};

View File

@@ -773,6 +773,8 @@ export interface TrainScheduleDetail {
loadingStatus?: "LOADED" | "UNLOADED";
wagonAssigned?: boolean;
isGovernment?: boolean;
/** Shipping-line bookings never prepay — FULLY_EXECUTED is boardable. */
shippingLineCompanyId?: string | null;
}>;
/** Ordered corridor stops (route milestones) — for per-segment occupancy. */
stops?: Array<{ yardId: string; label: string }>;
@@ -944,6 +946,11 @@ export interface UpdateCheckpointPayload extends CheckpointHandlingTimes {
export interface DispatchSchedulePayload {
/** Actual departure; defaults to now. Past OK, future rejected. */
actualDepartureAt?: string;
/**
* Origin-yard bookings staff confirmed loaded; every other unloaded origin
* boarder is unassigned back to the pool. Omit to auto-load all (legacy).
*/
loadedBookingIds?: string[];
}
export interface TrainScheduleFilters {