mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 07:45:45 +00:00
Merge pull request #1414 from Tria-plc/freight_feature/usermanagement
feat: enhance train scheduling and contract management features
This commit is contained in:
@@ -62,15 +62,45 @@ function isBulk(template: ContractTemplate): boolean {
|
||||
|
||||
// System container codes are DIRECTION_CONTAINER(_CUSTOMS); intercity is
|
||||
// domestic and crosses no border, so it has no customs variant at all — hence
|
||||
// null rather than false, which would wrongly read as a deliberate "client
|
||||
// clears its own customs" choice.
|
||||
function customsVariant(template: ContractTemplate): boolean | null {
|
||||
if (isBulk(template)) return template.withCustoms ?? null;
|
||||
if (template.code.endsWith("_NO_CUSTOMS")) return false;
|
||||
if (template.code.endsWith("_CUSTOMS")) return true;
|
||||
// null rather than "WITHOUT", which would wrongly read as a deliberate "client
|
||||
// clears its own customs" choice. "ETHIOPIAN" is the with-customs variant
|
||||
// restricted to Ethiopian-side clearing (Djibouti stays with the client).
|
||||
type CustomsVariant = "WITH" | "WITHOUT" | "ETHIOPIAN" | null;
|
||||
|
||||
function customsVariant(template: ContractTemplate): CustomsVariant {
|
||||
if (isBulk(template)) {
|
||||
if (template.withCustoms == null) return null;
|
||||
if (!template.withCustoms) return "WITHOUT";
|
||||
return template.ethiopianCustomsOnly ? "ETHIOPIAN" : "WITH";
|
||||
}
|
||||
if (template.code.endsWith("_NO_CUSTOMS")) return "WITHOUT";
|
||||
if (template.code.endsWith("_ETHIOPIAN_CUSTOMS")) return "ETHIOPIAN";
|
||||
if (template.code.endsWith("_CUSTOMS")) return "WITH";
|
||||
return null;
|
||||
}
|
||||
|
||||
const CUSTOMS_BADGE: Record<
|
||||
Exclude<CustomsVariant, null>,
|
||||
{ label: string; color: string; tooltip: string }
|
||||
> = {
|
||||
WITH: {
|
||||
label: "With customs",
|
||||
color: "teal",
|
||||
tooltip: "Used when the contract has customs clearing enabled",
|
||||
},
|
||||
ETHIOPIAN: {
|
||||
label: "Ethiopian customs",
|
||||
color: "indigo",
|
||||
tooltip:
|
||||
"Used when the service type includes Ethiopian customs clearing only — Djibouti clearing stays with the client",
|
||||
},
|
||||
WITHOUT: {
|
||||
label: "No customs",
|
||||
color: "gray",
|
||||
tooltip: "Used when the client handles its own customs clearing",
|
||||
},
|
||||
};
|
||||
|
||||
// Bulk templates carry the direction on the row; the fixed container codes
|
||||
// carry it as the code prefix.
|
||||
function directionOf(template: ContractTemplate): string {
|
||||
@@ -116,7 +146,7 @@ export default function ContractTemplatesPage() {
|
||||
<PageContainer>
|
||||
<PageHeader
|
||||
title="Contract templates"
|
||||
subtitle="The five container contract documents are built in — one per trade direction and customs-clearing option. Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
|
||||
subtitle="The container contract documents are built in — one per trade direction and customs-clearing option (with customs, Ethiopian customs only, without). Bulk contracts are written per cargo type: create one template per trade direction, customs option and commodity. Articles are fully editable."
|
||||
action={
|
||||
canCreate ? (
|
||||
<Button
|
||||
@@ -285,9 +315,17 @@ function CreateTemplateModal({
|
||||
onChange={setWithCustoms}
|
||||
data={[
|
||||
{ value: "true", label: "With customs clearing" },
|
||||
{ value: "ethiopian", label: "Ethiopian customs only" },
|
||||
{ value: "false", label: "Without customs clearing" },
|
||||
]}
|
||||
/>
|
||||
{withCustoms === "ethiopian" && (
|
||||
<Text size="xs" c="dimmed" mt={6}>
|
||||
Used for service types marked “Ethiopian customs only”: the
|
||||
Service Provider clears the Ethiopian side, Djibouti clearing
|
||||
stays with the client.
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -316,8 +354,15 @@ function CreateTemplateModal({
|
||||
{
|
||||
cargoTypeId,
|
||||
tradeDirection: direction,
|
||||
// Omitted for intercity — the API rejects the flag there.
|
||||
...(intercity ? {} : { withCustoms: withCustoms === "true" }),
|
||||
// Omitted for intercity — the API rejects the flags there.
|
||||
...(intercity
|
||||
? {}
|
||||
: {
|
||||
withCustoms: withCustoms !== "false",
|
||||
...(withCustoms === "ethiopian"
|
||||
? { ethiopianCustomsOnly: true }
|
||||
: {}),
|
||||
}),
|
||||
},
|
||||
{
|
||||
onSuccess: (template) =>
|
||||
@@ -394,16 +439,9 @@ function TemplateCard({
|
||||
</Group>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{customs !== null && (
|
||||
<Tooltip
|
||||
label={
|
||||
customs
|
||||
? "Used when the contract has customs clearing enabled"
|
||||
: "Used when the client handles its own customs clearing"
|
||||
}
|
||||
withArrow
|
||||
>
|
||||
<Badge size="sm" variant="light" color={customs ? "teal" : "gray"}>
|
||||
{customs ? "With customs" : "No customs"}
|
||||
<Tooltip label={CUSTOMS_BADGE[customs].tooltip} withArrow>
|
||||
<Badge size="sm" variant="light" color={CUSTOMS_BADGE[customs].color}>
|
||||
{CUSTOMS_BADGE[customs].label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -95,6 +95,7 @@ const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ label: "None", value: RULE_ENGINE_SELECT_NONE },
|
||||
{ label: "Per ton (bulk)", value: "PER_TON" },
|
||||
{ label: "Per item (break-bulk)", value: "PER_ITEM" },
|
||||
{ label: "Based on number of wagons", value: "NUMBER_OF_WAGONS" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -597,7 +598,11 @@ function CargoRow({
|
||||
{node.unitOfMeasure ? (
|
||||
<Tooltip label="How bookings measure this cargo" withArrow>
|
||||
<Badge size="xs" variant="light" color="teal" radius="sm">
|
||||
{node.unitOfMeasure === "PER_ITEM" ? "Per item" : "Per ton"}
|
||||
{node.unitOfMeasure === "PER_ITEM"
|
||||
? "Per item"
|
||||
: node.unitOfMeasure === "NUMBER_OF_WAGONS"
|
||||
? "By wagons"
|
||||
: "Per ton"}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { isAxiosError } from "axios";
|
||||
@@ -55,7 +56,10 @@ import { api } from "@/services/api";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
|
||||
import type {
|
||||
TrainCompositionWagon,
|
||||
WagonDetachRequestRow,
|
||||
} from "@/services/trainBuilder.service";
|
||||
|
||||
const parseError = (error: unknown, fallback: string) => {
|
||||
if (isAxiosError(error)) {
|
||||
@@ -95,8 +99,26 @@ export default function TrainBuilderDetailPage() {
|
||||
setMaintenanceTarget(null);
|
||||
setMaintenanceNote("");
|
||||
};
|
||||
// Detach-approval flow: on a SCHEDULED run, detach/maintenance is filed as a
|
||||
// request (with reason) and executed by a second staffer's approval.
|
||||
const [requestTarget, setRequestTarget] = useState<{
|
||||
wagon: TrainCompositionWagon;
|
||||
action: "DETACH" | "MAINTENANCE";
|
||||
} | null>(null);
|
||||
const [requestReason, setRequestReason] = useState("");
|
||||
const closeRequest = () => {
|
||||
setRequestTarget(null);
|
||||
setRequestReason("");
|
||||
};
|
||||
const [rejectTarget, setRejectTarget] = useState<WagonDetachRequestRow | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState("");
|
||||
const closeReject = () => {
|
||||
setRejectTarget(null);
|
||||
setRejectNote("");
|
||||
};
|
||||
const { user } = useAuth();
|
||||
const canAssign = hasPermission(user, FREIGHT_PERMS.trains.assignWagons);
|
||||
const canApproveDetach = hasPermission(user, FREIGHT_PERMS.trains.approveWagonDetach);
|
||||
const canChangeLocomotives = hasPermission(user, FREIGHT_PERMS.trains.changeLocomotives);
|
||||
const canChangeYard = hasPermission(user, FREIGHT_PERMS.trains.changeYard);
|
||||
const canChangeWagonYard = hasPermission(user, FREIGHT_PERMS.trains.changeWagonYard);
|
||||
@@ -121,12 +143,42 @@ export default function TrainBuilderDetailPage() {
|
||||
api.trainBuilder.sendWagonToMaintenance.mutationOptions(),
|
||||
);
|
||||
const reorderWagons = useMutation(api.trainBuilder.reorderWagons.mutationOptions());
|
||||
const detachRequestsQuery = useQuery(
|
||||
api.trainBuilder.detachRequests.queryOptions({
|
||||
input: { id },
|
||||
enabled: Boolean(id),
|
||||
}),
|
||||
);
|
||||
const createDetachRequest = useMutation(
|
||||
api.trainBuilder.createDetachRequest.mutationOptions(),
|
||||
);
|
||||
const approveDetachRequest = useMutation(
|
||||
api.trainBuilder.approveDetachRequest.mutationOptions(),
|
||||
);
|
||||
const rejectDetachRequest = useMutation(
|
||||
api.trainBuilder.rejectDetachRequest.mutationOptions(),
|
||||
);
|
||||
const disband = useMutation(api.trainBuilder.disband.mutationOptions());
|
||||
const deactivate = useMutation(api.trainBuilder.deactivate.mutationOptions());
|
||||
const activate = useMutation(api.trainBuilder.activate.mutationOptions());
|
||||
|
||||
const composition = compositionQuery.data;
|
||||
|
||||
// Approval kicks in once a run is SCHEDULED. DRAFT stays direct-edit; a
|
||||
// dispatched train is frozen outright (composition.editable is false).
|
||||
const requiresDetachApproval = (composition?.activeSchedules ?? []).some(
|
||||
(s) => s.status === "SCHEDULED",
|
||||
);
|
||||
const detachRequests = useMemo(
|
||||
() => detachRequestsQuery.data ?? [],
|
||||
[detachRequestsQuery.data],
|
||||
);
|
||||
const pendingDetachRequests = detachRequests.filter((r) => r.status === "PENDING");
|
||||
const pendingWagonIds = useMemo(
|
||||
() => new Set(detachRequests.filter((r) => r.status === "PENDING").map((r) => r.wagonId)),
|
||||
[detachRequests],
|
||||
);
|
||||
|
||||
// The diagram memoizes off its `locomotives`/`wagons` props; building those
|
||||
// arrays inline in JSX would hand it a new identity on every render and
|
||||
// re-normalize + repaint every car for each keystroke or pending mutation.
|
||||
@@ -217,15 +269,33 @@ export default function TrainBuilderDetailPage() {
|
||||
},
|
||||
[withToast, reorderWagons.mutateAsync, trainId],
|
||||
);
|
||||
const wagons = composition?.wagons;
|
||||
const openDetachRequest = useCallback(
|
||||
(wagonId: string, action: "DETACH" | "MAINTENANCE") => {
|
||||
if (pendingWagonIds.has(wagonId)) {
|
||||
toast({
|
||||
title: "A detach request for this wagon is already pending approval",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const wagon = wagons?.find((w) => w.id === wagonId);
|
||||
if (wagon) setRequestTarget({ wagon, action });
|
||||
},
|
||||
[pendingWagonIds, wagons, toast],
|
||||
);
|
||||
const handleRemove = useCallback(
|
||||
(wagonId: string) => {
|
||||
if (!trainId) return;
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagonId, "DETACH");
|
||||
return;
|
||||
}
|
||||
void withToast(
|
||||
() => removeWagon.mutateAsync({ id: trainId, wagonId }),
|
||||
"Could not detach wagon",
|
||||
);
|
||||
},
|
||||
[withToast, removeWagon.mutateAsync, trainId],
|
||||
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
|
||||
);
|
||||
const handleChangeWagonYard = useCallback(
|
||||
(wagonId: string, currentYardId: string) => {
|
||||
@@ -248,8 +318,14 @@ export default function TrainBuilderDetailPage() {
|
||||
[withToast, setWagonsYard.mutateAsync, trainId],
|
||||
);
|
||||
const handleMaintenance = useCallback(
|
||||
(wagon: TrainCompositionWagon) => setMaintenanceTarget(wagon),
|
||||
[],
|
||||
(wagon: TrainCompositionWagon) => {
|
||||
if (requiresDetachApproval) {
|
||||
openDetachRequest(wagon.id, "MAINTENANCE");
|
||||
return;
|
||||
}
|
||||
setMaintenanceTarget(wagon);
|
||||
},
|
||||
[requiresDetachApproval, openDetachRequest],
|
||||
);
|
||||
|
||||
if (compositionQuery.isLoading) {
|
||||
@@ -487,6 +563,117 @@ export default function TrainBuilderDetailPage() {
|
||||
))}
|
||||
</Group>
|
||||
|
||||
{detachRequests.length ? (
|
||||
<Card>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text fw={600}>Detach approvals</Text>
|
||||
{pendingDetachRequests.length ? (
|
||||
<Badge color="yellow" variant="light">
|
||||
{pendingDetachRequests.length} pending
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
While this train is on a scheduled run, detaching a wagon (or sending it to
|
||||
maintenance) needs a second staff member's approval. Decided requests stay
|
||||
here as the audit trail.
|
||||
</Text>
|
||||
{detachRequests.map((req) => {
|
||||
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
|
||||
return (
|
||||
<Group key={req.id} justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" fw={600} ff="monospace">
|
||||
{req.wagonNumber}
|
||||
</Text>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={req.action === "MAINTENANCE" ? "orange" : "red"}
|
||||
>
|
||||
{req.action === "MAINTENANCE" ? "To maintenance" : "Detach"}
|
||||
</Badge>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={
|
||||
req.status === "PENDING"
|
||||
? "yellow"
|
||||
: req.status === "APPROVED"
|
||||
? "green"
|
||||
: "gray"
|
||||
}
|
||||
>
|
||||
{req.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
Requested by {req.requestedBy ?? "unknown"} ·{" "}
|
||||
{new Date(req.requestedAt).toLocaleString()} — {req.reason}
|
||||
</Text>
|
||||
{req.status !== "PENDING" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{req.status === "APPROVED" ? "Approved" : "Rejected"} by{" "}
|
||||
{req.decidedBy ?? "unknown"}
|
||||
{req.decidedAt ? ` · ${new Date(req.decidedAt).toLocaleString()}` : ""}
|
||||
{req.decisionNote ? ` — ${req.decisionNote}` : ""}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
{req.status === "PENDING" && canApproveDetach ? (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Tooltip
|
||||
label="You filed this request — a different staff member must approve it"
|
||||
disabled={!isOwn}
|
||||
withArrow
|
||||
>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="green"
|
||||
disabled={isOwn}
|
||||
loading={approveDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await approveDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: req.id,
|
||||
});
|
||||
toast({
|
||||
title: `Wagon ${req.wagonNumber} ${
|
||||
req.action === "MAINTENANCE"
|
||||
? "sent to maintenance"
|
||||
: "detached"
|
||||
}`,
|
||||
});
|
||||
}, "Could not approve request")
|
||||
}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => setRejectTarget(req)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
) : req.status === "PENDING" ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Awaiting approval
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Stack gap="sm">
|
||||
<TrainCompositionDiagram
|
||||
locomotives={diagramLocomotives}
|
||||
@@ -681,6 +868,129 @@ export default function TrainBuilderDetailPage() {
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(requestTarget)}
|
||||
onClose={closeRequest}
|
||||
title={
|
||||
<Text fw={600}>
|
||||
{requestTarget?.action === "MAINTENANCE"
|
||||
? "Request maintenance approval?"
|
||||
: "Request detach approval?"}
|
||||
</Text>
|
||||
}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Train{" "}
|
||||
<Text span fw={700} c="dark">
|
||||
{trainRunLabel}
|
||||
</Text>{" "}
|
||||
is on a scheduled run, so wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{requestTarget?.wagon.wagonNumber}
|
||||
</Text>{" "}
|
||||
is not detached now — your request goes to a staff member with approval
|
||||
rights, and the{" "}
|
||||
{requestTarget?.action === "MAINTENANCE" ? "maintenance move" : "detach"}{" "}
|
||||
happens the moment they approve it.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
placeholder="Why must this wagon leave the scheduled consist? (required)"
|
||||
value={requestReason}
|
||||
onChange={(e) => setRequestReason(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeRequest}>
|
||||
Keep in consist
|
||||
</Button>
|
||||
<Button
|
||||
color={requestTarget?.action === "MAINTENANCE" ? "orange" : "red"}
|
||||
leftSection={
|
||||
requestTarget?.action === "MAINTENANCE" ? (
|
||||
<Wrench size={16} />
|
||||
) : (
|
||||
<Trash2 size={16} />
|
||||
)
|
||||
}
|
||||
disabled={!requestReason.trim()}
|
||||
loading={createDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await createDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
wagonId: requestTarget!.wagon.id,
|
||||
action: requestTarget!.action,
|
||||
reason: requestReason.trim(),
|
||||
});
|
||||
toast({
|
||||
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
|
||||
});
|
||||
closeRequest();
|
||||
}, "Could not file the request")
|
||||
}
|
||||
>
|
||||
Request approval
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(rejectTarget)}
|
||||
onClose={closeReject}
|
||||
title={<Text fw={600}>Reject this request?</Text>}
|
||||
radius="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Wagon{" "}
|
||||
<Text span fw={700} ff="monospace" c="dark">
|
||||
{rejectTarget?.wagonNumber}
|
||||
</Text>{" "}
|
||||
stays in the consist. The requester sees your note in the request history.
|
||||
</Text>
|
||||
<Textarea
|
||||
label="Why is it rejected?"
|
||||
placeholder="Required"
|
||||
value={rejectNote}
|
||||
onChange={(e) => setRejectNote(e.currentTarget.value)}
|
||||
autosize
|
||||
minRows={2}
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeReject}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
disabled={!rejectNote.trim()}
|
||||
loading={rejectDetachRequest.isPending}
|
||||
onClick={() =>
|
||||
void withToast(async () => {
|
||||
await rejectDetachRequest.mutateAsync({
|
||||
id: composition.id,
|
||||
requestId: rejectTarget!.id,
|
||||
note: rejectNote.trim(),
|
||||
});
|
||||
toast({ title: `Request for wagon ${rejectTarget!.wagonNumber} rejected` });
|
||||
closeReject();
|
||||
}, "Could not reject the request")
|
||||
}
|
||||
>
|
||||
Reject request
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={deactivateOpen}
|
||||
onClose={() => setDeactivateOpen(false)}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { isAxiosError } from "axios";
|
||||
import {
|
||||
@@ -65,6 +66,7 @@ import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPane
|
||||
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
|
||||
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
|
||||
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
|
||||
import { StationWorkControls } from "@/components/trainScheduling/StationWorkControls";
|
||||
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
|
||||
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
|
||||
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
|
||||
@@ -490,6 +492,17 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const dispatchLeftCount = pendingOriginBoarders.filter(
|
||||
(b) => !b.isGovernment && !dispatchLoadedIds.has(b.id),
|
||||
).length;
|
||||
// Origin loading time window: dispatch (which marks the ticked boarders
|
||||
// loaded) is server-rejected until "Start loading" was clicked for the
|
||||
// origin yard, so the button mirrors that gate.
|
||||
const originLoadingLog = originYardId
|
||||
? schedule.stationWorkLogs?.[originYardId]?.loading
|
||||
: undefined;
|
||||
const originLoadingStarted = Boolean(originLoadingLog?.startedAt);
|
||||
const dispatchBoardersKept = pendingOriginBoarders.some(
|
||||
(b) => b.isGovernment || dispatchLoadedIds.has(b.id),
|
||||
);
|
||||
const dispatchNeedsLoadingStart = dispatchBoardersKept && !originLoadingStarted;
|
||||
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
@@ -936,6 +949,27 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
{originYardId ? (
|
||||
<Paper p="md" radius="lg" withBorder>
|
||||
<Stack gap={6}>
|
||||
<Text fw={600} size="sm">
|
||||
Loading at {schedule.originStation?.label ?? "the origin yard"}
|
||||
</Text>
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={originYardId}
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
{dispatchNeedsLoadingStart ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
Start loading before dispatching — the ticked bookings are marked
|
||||
loaded at dispatch, which needs an open loading window.
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : null}
|
||||
<Group>
|
||||
{canDispatch ? (
|
||||
<Button
|
||||
@@ -1560,6 +1594,14 @@ export default function TrainScheduleV2DetailPage() {
|
||||
Cargo boarding at {schedule.originStation?.label ?? "the origin yard"} —
|
||||
tick what was loaded
|
||||
</Text>
|
||||
{originYardId ? (
|
||||
<StationWorkControls
|
||||
scheduleId={scheduleId}
|
||||
yardId={originYardId}
|
||||
phase="loading"
|
||||
log={originLoadingLog}
|
||||
/>
|
||||
) : null}
|
||||
<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
|
||||
@@ -1656,14 +1698,20 @@ export default function TrainScheduleV2DetailPage() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
onClick={() => void runDispatch()}
|
||||
<Tooltip
|
||||
label="Start loading at the origin station first — dispatch marks the ticked bookings loaded"
|
||||
disabled={!dispatchNeedsLoadingStart}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
leftSection={<Send size={16} />}
|
||||
loading={dispatch.isPending}
|
||||
disabled={dispatchNeedsLoadingStart}
|
||||
onClick={() => void runDispatch()}
|
||||
>
|
||||
{hasDispatchWarnings ? "Dispatch anyway" : "Dispatch train"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user