mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -209,6 +209,14 @@ export function WagonCancellationCard({
|
||||
// Non-customs: container number / seal / VGM may change at rebook. Customs
|
||||
// (Path B) credits are rebooked by GL from the backoffice instead.
|
||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||
// Odd-20ft credit: the rebooked booking shares a wagon again and only GL can
|
||||
// pick the partner — GL rebooks it whatever the contract kind (server enforces).
|
||||
const oddFt20Credit =
|
||||
Object.entries(creditRow?.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([sizeKey]) => parseInt(sizeKey, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
const [rebookDrafts, setRebookDrafts] = useState<RebookUnitDraft[] | null>(null);
|
||||
const snapshotUnits = creditRow?.cancelledQuantities?.units ?? [];
|
||||
const drafts = rebookDrafts ?? draftsFromSnapshot(snapshotUnits);
|
||||
@@ -282,10 +290,11 @@ export function WagonCancellationCard({
|
||||
is available. Pick a shipment day to rebook them as a new paid
|
||||
booking (no further payment needed).
|
||||
</Alert>
|
||||
{isCustoms ? (
|
||||
{isCustoms || oddFt20Credit ? (
|
||||
<Text fz={13} c="#475569">
|
||||
This is a customs-cleared booking — Global Logistics will rebook
|
||||
the credit for you.
|
||||
{isCustoms
|
||||
? "This is a customs-cleared booking — Global Logistics will rebook the credit for you."
|
||||
: "Your credit includes an odd 20ft container that must share a wagon with another booking — Global Logistics will rebook it for you and pair the wagon. Please contact EDR staff."}
|
||||
</Text>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -50,6 +50,15 @@ export function RebookWagonsButton({
|
||||
);
|
||||
const showEditor = Boolean(editableUnits) && drafts.length > 0;
|
||||
|
||||
// An odd-20ft credit shares a wagon again on rebook, and only EDR staff can
|
||||
// pick the partner booking — the portal cannot rebook it (server enforces).
|
||||
const oddFt20 =
|
||||
Object.entries(cancellation.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([sizeKey]) => parseInt(sizeKey, 10) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0) %
|
||||
2 ===
|
||||
1;
|
||||
|
||||
const rebook = useMutation({
|
||||
mutationFn: () =>
|
||||
bookingsService.rebookWagonCancellation(cancellation.id, {
|
||||
@@ -67,6 +76,16 @@ export function RebookWagonsButton({
|
||||
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||
});
|
||||
|
||||
if (oddFt20) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
Your credit includes an odd 20ft container that must share a wagon with
|
||||
another booking — Global Logistics will rebook it for you and pair the
|
||||
wagon. Please contact EDR staff.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
|
||||
Reference in New Issue
Block a user