Merge pull request #1414 from Tria-plc/freight_feature/usermanagement

feat: enhance train scheduling and contract management features
This commit is contained in:
marshal
2026-08-26 00:46:17 +03:00
committed by GitHub
67 changed files with 2998 additions and 255 deletions

View File

@@ -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>
)}

View File

@@ -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}

View File

@@ -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)}

View File

@@ -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>