mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 20:40:55 +00:00
Merge pull request #1442 from Tria-plc/credit-invoice
Container return association withcompany
This commit is contained in:
@@ -16,11 +16,22 @@ export function useCompanyOptions() {
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
|
||||
// Company names are not unique — Mantine throws on duplicate option values,
|
||||
// so the list is deduped by the trimmed name.
|
||||
const names = [...new Set(companies.map((c) => c.name.trim()).filter(Boolean))];
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
names: companies.map((c) => c.name),
|
||||
/** Exact (case-insensitive) name match → company id, else undefined. */
|
||||
resolveId: (name: string): string | undefined =>
|
||||
companies.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase())?.id,
|
||||
names,
|
||||
/**
|
||||
* Name → company id, only when exactly one company carries that name. An
|
||||
* ambiguous name resolves to nothing rather than to an arbitrary company:
|
||||
* the container keeps the typed name and no wrong customer is attached.
|
||||
*/
|
||||
resolveId: (name: string): string | undefined => {
|
||||
const key = name.trim().toLowerCase();
|
||||
const matches = companies.filter((c) => c.name.trim().toLowerCase() === key);
|
||||
return matches.length === 1 ? matches[0].id : undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ export const QUERY_KEYS = {
|
||||
["train-scheduling", "schedules", filters ?? {}] as const,
|
||||
scheduleById: (id: string) => ["train-scheduling", "schedule", id] as const,
|
||||
track: (id: string) => ["train-scheduling", "track", id] as const,
|
||||
marshallingStops: (id: string) =>
|
||||
["train-scheduling", "marshalling-stops", id] as const,
|
||||
batchBoard: (filters?: unknown) =>
|
||||
["train-scheduling", "batch-board", "list", filters ?? {}] as const,
|
||||
batchBoardDetail: (scheduleId: string) =>
|
||||
|
||||
@@ -537,6 +537,10 @@ export const URL_CONSTANTS = {
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
INTERCITY_MARSHALLING_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/intercity/marshalling/document`,
|
||||
MARSHALLING_STOPS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/stops`,
|
||||
MARSHALLING_DOCUMENT_AT: (id: string, stopIndex: number) =>
|
||||
`/train-scheduling/schedules/${id}/marshalling/document/${stopIndex}`,
|
||||
CHECKPOINTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/checkpoints`,
|
||||
CHECKPOINT: (id: string, sequenceNo: number) =>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Route,
|
||||
TrainFront,
|
||||
} from "lucide-react";
|
||||
import { Box, Button, Group, Loader, Stack, Text } from "@mantine/core";
|
||||
import { Box, Button, Group, Loader, Menu, Stack, Text } from "@mantine/core";
|
||||
|
||||
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
|
||||
import { CheckpointLogTable } from "@/components/trainScheduling/CheckpointLogTable";
|
||||
@@ -119,18 +119,34 @@ export default function TrainScheduleTrackPage() {
|
||||
enabled: Boolean(scheduleId) && trackQuery.data?.status === "DISPATCHED",
|
||||
}),
|
||||
);
|
||||
// Marshalling 2: the current on-board list, reprinted after station work.
|
||||
// Marshalling: the on-board list, reprinted after station work. Numbered
|
||||
// per corridor stop that actually coupled/uncoupled something (Marshalling
|
||||
// 2, 3, 4…) — falls back to the single "current position" doc when nothing
|
||||
// has happened yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled:
|
||||
Boolean(scheduleId) &&
|
||||
["DISPATCHED", "ARRIVED"].includes(trackQuery.data?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
const intercityMarshalling = useMutation({
|
||||
mutationFn: () =>
|
||||
trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
mutationFn: (stopIndex?: number) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(scheduleId ?? "", stopIndex)
|
||||
: trainSchedulingService.downloadIntercityMarshallingDocument(scheduleId ?? ""),
|
||||
});
|
||||
const openIntercityMarshalling = async () => {
|
||||
const openIntercityMarshalling = async (stopIndex?: number) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await intercityMarshalling.mutateAsync();
|
||||
const opened = openPdfBlob(blob, `intercity-marshalling-${scheduleId}.pdf`, pdfWindow);
|
||||
const blob = await intercityMarshalling.mutateAsync(stopIndex);
|
||||
const filename =
|
||||
stopIndex != null ? `marshalling-${stopIndex}-${scheduleId}.pdf` : `intercity-marshalling-${scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Intercity marshalling ready",
|
||||
title: stopIndex != null ? `Marshalling ${stopIndex} ready` : "Intercity marshalling ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
@@ -313,7 +329,7 @@ export default function TrainScheduleTrackPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<Box style={{ flex: 1 }} />
|
||||
{inTransit || arrived ? (
|
||||
{(inTransit || arrived) && marshallingStops.length === 0 ? (
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
@@ -325,6 +341,31 @@ export default function TrainScheduleTrackPage() {
|
||||
Intercity Marshalling
|
||||
</Button>
|
||||
) : null}
|
||||
{(inTransit || arrived) && marshallingStops.length > 0 ? (
|
||||
<Menu position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="default"
|
||||
radius={9}
|
||||
size="compact-sm"
|
||||
leftSection={<FileText size={15} color={T.brand} />}
|
||||
loading={intercityMarshalling.isPending}
|
||||
>
|
||||
Marshalling
|
||||
</Button>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
onClick={() => void openIntercityMarshalling(stop.stopIndex)}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{/* ── Two-column work surface ── */}
|
||||
|
||||
@@ -257,13 +257,34 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const switchGov = useMutation(api.trainScheduling.switchGovernmentBooking.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction, variant }: { id: string; direction?: string | null; variant?: "INTERCITY" }) =>
|
||||
variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
mutationFn: ({
|
||||
id,
|
||||
direction,
|
||||
variant,
|
||||
stopIndex,
|
||||
}: {
|
||||
id: string;
|
||||
direction?: string | null;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) =>
|
||||
stopIndex != null
|
||||
? trainSchedulingService.downloadMarshallingDocumentAt(id, stopIndex)
|
||||
: variant === "INTERCITY"
|
||||
? trainSchedulingService.downloadIntercityMarshallingDocument(id)
|
||||
: direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
// Numbered marshalling docs (Marshalling 2, 3, 4…) — one per corridor stop
|
||||
// that actually coupled/uncoupled something. Empty when nothing has yet.
|
||||
const marshallingStopsQuery = useQuery(
|
||||
api.trainScheduling.marshallingStops.queryOptions({
|
||||
input: { id: scheduleId ?? "" },
|
||||
enabled: Boolean(scheduleId) && ["DISPATCHED", "ARRIVED"].includes(schedule?.status ?? ""),
|
||||
}),
|
||||
);
|
||||
const marshallingStops = marshallingStopsQuery.data ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
const operation = gatepassQuery.data;
|
||||
@@ -506,6 +527,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
successDescription?: string;
|
||||
errorTitle?: string;
|
||||
variant?: "INTERCITY";
|
||||
stopIndex?: number;
|
||||
}) => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
@@ -513,13 +535,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
variant: options?.variant,
|
||||
stopIndex: options?.stopIndex,
|
||||
});
|
||||
const prefix =
|
||||
options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
options?.stopIndex != null
|
||||
? `marshalling-${options.stopIndex}`
|
||||
: options?.variant === "INTERCITY"
|
||||
? "intercity-marshalling"
|
||||
: schedule.direction === "EXPORT"
|
||||
? "export-marshalling"
|
||||
: "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
@@ -1108,7 +1133,7 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Marshalling PDF
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) && marshallingStops.length === 0 ? (
|
||||
<Menu.Item
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
@@ -1122,6 +1147,25 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Intercity Marshalling
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
{/* One item per corridor stop that actually coupled/uncoupled
|
||||
something (Marshalling 2, 3, 4…) — replaces the single
|
||||
"current position" item once anything has happened. */}
|
||||
{marshallingStops.map((stop) => (
|
||||
<Menu.Item
|
||||
key={stop.stopIndex}
|
||||
leftSection={<FileText size={15} />}
|
||||
disabled={downloadMarshalling.isPending}
|
||||
onClick={() =>
|
||||
void openMarshallingDocument({
|
||||
title: `Marshalling ${stop.stopIndex} ready`,
|
||||
successDescription: `${stop.yardLabel} — coupled/uncoupled wagons included.`,
|
||||
stopIndex: stop.stopIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
{`Marshalling ${stop.stopIndex} — ${stop.yardLabel}`}
|
||||
</Menu.Item>
|
||||
))}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
|
||||
@@ -85,6 +85,7 @@ import type {
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
MarshallingStop,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
TrainScheduleFilters,
|
||||
@@ -595,6 +596,13 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.track(id),
|
||||
),
|
||||
|
||||
marshallingStops: endpoint<{ id: string }, MarshallingStop[]>(
|
||||
"train-scheduling",
|
||||
"marshalling-stops",
|
||||
({ id }) => trainSchedulingService.getMarshallingStops(id),
|
||||
({ id }) => QUERY_KEYS.TRAIN_SCHEDULING.marshallingStops(id),
|
||||
),
|
||||
|
||||
unassignedBookings: endpoint<
|
||||
{ scheduleId: string },
|
||||
UnassignedBookingsResponse
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
UpdateCheckpointPayload,
|
||||
DispatchSchedulePayload,
|
||||
StaffBookingWindow,
|
||||
MarshallingStop,
|
||||
ScheduleMergePreview,
|
||||
TrainScheduleDetail,
|
||||
UpdateScheduleWindowRulePayload,
|
||||
@@ -760,6 +761,24 @@ export const trainSchedulingService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getMarshallingStops: async (scheduleId: string): Promise<MarshallingStop[]> => {
|
||||
const response = await client.get<MarshallingStop[]>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_STOPS(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
downloadMarshallingDocumentAt: async (
|
||||
scheduleId: string,
|
||||
stopIndex: number,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.MARSHALLING_DOCUMENT_AT(scheduleId, stopIndex),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
|
||||
@@ -933,6 +933,19 @@ export interface TrainCheckpoint {
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One corridor stop with a logged consist change (coupled/uncoupled/switched)
|
||||
* — its own numbered marshalling document exists. `stopIndex` starts at 2;
|
||||
* origin is always Marshalling 1 (the plain import/export load list, not
|
||||
* this list). A stop with only a routine checkpoint never appears here.
|
||||
*/
|
||||
export interface MarshallingStop {
|
||||
stopIndex: number;
|
||||
yardId: string;
|
||||
yardLabel: string;
|
||||
firstOccurredAt: string;
|
||||
}
|
||||
|
||||
/** The four station-work stamps, as a payload fragment both endpoints accept. */
|
||||
export interface CheckpointHandlingTimes {
|
||||
unloadingStartedAt?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user