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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-28 10:34:31 +03:00
committed by GitHub
32 changed files with 1762 additions and 646 deletions

View File

@@ -90,17 +90,8 @@ export default function TrainBuilderDetailPage() {
const [yardModalOpen, setYardModalOpen] = useState(false);
const [disbandOpen, setDisbandOpen] = useState(false);
const [deactivateOpen, setDeactivateOpen] = useState(false);
const [maintenanceTarget, setMaintenanceTarget] =
useState<TrainCompositionWagon | null>(null);
const [maintenanceNote, setMaintenanceNote] = useState("");
// Clearing the note with the target stops one wagon's reason being carried
// over onto the next wagon sent to maintenance.
const closeMaintenance = () => {
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.
// Every detach / maintenance move asks for a reason first — it is recorded
// as an auto-approved audit row and on the train's wagon history.
const [requestTarget, setRequestTarget] = useState<{
wagon: TrainCompositionWagon;
action: "DETACH" | "MAINTENANCE";
@@ -110,15 +101,8 @@ export default function TrainBuilderDetailPage() {
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);
@@ -149,35 +133,17 @@ export default function TrainBuilderDetailPage() {
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
@@ -270,32 +236,19 @@ export default function TrainBuilderDetailPage() {
[withToast, reorderWagons.mutateAsync, trainId],
);
const wagons = composition?.wagons;
const openDetachRequest = useCallback(
const openDetachReason = 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],
[wagons],
);
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",
);
openDetachReason(wagonId, "DETACH");
},
[withToast, removeWagon.mutateAsync, trainId, requiresDetachApproval, openDetachRequest],
[trainId, openDetachReason],
);
const handleChangeWagonYard = useCallback(
(wagonId: string, currentYardId: string) => {
@@ -319,13 +272,9 @@ export default function TrainBuilderDetailPage() {
);
const handleMaintenance = useCallback(
(wagon: TrainCompositionWagon) => {
if (requiresDetachApproval) {
openDetachRequest(wagon.id, "MAINTENANCE");
return;
}
setMaintenanceTarget(wagon);
openDetachReason(wagon.id, "MAINTENANCE");
},
[requiresDetachApproval, openDetachRequest],
[openDetachReason],
);
if (compositionQuery.isLoading) {
@@ -502,9 +451,11 @@ export default function TrainBuilderDetailPage() {
</Alert>
) : null}
{!composition.editable ? (
<Alert color="yellow" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run its composition is frozen until arrival.
{(composition.activeSchedules ?? []).some((s) => s.status === "DISPATCHED") ? (
<Alert color="blue" icon={<AlertTriangle size={16} />}>
This train is out on a dispatched run. You can still edit its composition
the dispatched run keeps the wagon plan it departed with, and your changes
apply to scheduled (not yet departed) runs only.
</Alert>
) : null}
@@ -563,7 +514,7 @@ export default function TrainBuilderDetailPage() {
))}
</Group>
{detachRequests.length ? (
{/* {detachRequests.length ? (
<Card>
<Stack gap="sm">
<Group justify="space-between">
@@ -576,8 +527,8 @@ export default function TrainBuilderDetailPage() {
</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.
maintenance) requires a reason — recorded here as the audit trail of who
did it and why.
</Text>
{detachRequests.map((req) => {
const isOwn = Boolean(req.requestedById && user?.id === req.requestedById);
@@ -622,49 +573,9 @@ export default function TrainBuilderDetailPage() {
</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" ? (
{req.status === "PENDING" ? (
<Text size="xs" c="dimmed">
Awaiting approval
Legacy request — approval flow removed
</Text>
) : null}
</Group>
@@ -672,7 +583,7 @@ export default function TrainBuilderDetailPage() {
})}
</Stack>
</Card>
) : null}
) : null} */}
<Stack gap="sm">
<TrainCompositionDiagram
@@ -811,71 +722,14 @@ export default function TrainBuilderDetailPage() {
onClose={() => setYardModalOpen(false)}
/>
<Modal
opened={Boolean(maintenanceTarget)}
onClose={closeMaintenance}
title={<Text fw={600}>Send wagon to maintenance?</Text>}
radius="lg"
centered
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Wagon{" "}
<Text span fw={700} ff="monospace" c="dark">
{maintenanceTarget?.wagonNumber}
</Text>{" "}
is detached from train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
and set to MAINTENANCE it stays out of the available pool until it
clears. The detach is stamped with the time and this train's run
numbers in the wagon's history.
</Text>
<Textarea
label="Note"
placeholder="Optional note (e.g. reason for maintenance)"
value={maintenanceNote}
onChange={(e) => setMaintenanceNote(e.currentTarget.value)}
autosize
minRows={2}
/>
<Group justify="flex-end">
<Button variant="default" onClick={closeMaintenance}>
Keep in consist
</Button>
<Button
color="orange"
leftSection={<Wrench size={16} />}
loading={maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: maintenanceTarget!.id,
note: maintenanceNote.trim() || undefined,
});
toast({
title: `Wagon ${maintenanceTarget!.wagonNumber} sent to maintenance`,
});
closeMaintenance();
}, "Could not send wagon to maintenance")
}
>
Send to maintenance
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(requestTarget)}
onClose={closeRequest}
title={
<Text fw={600}>
{requestTarget?.action === "MAINTENANCE"
? "Request maintenance approval?"
: "Request detach approval?"}
? "Send wagon to maintenance?"
: "Detach wagon?"}
</Text>
}
radius="lg"
@@ -883,22 +737,28 @@ export default function TrainBuilderDetailPage() {
>
<Stack gap="md">
<Text size="sm" c="dimmed">
Train{" "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
is on a scheduled run, so wagon{" "}
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.
{requestTarget?.action === "MAINTENANCE"
? "leaves train "
: "is detached from train "}
<Text span fw={700} c="dark">
{trainRunLabel}
</Text>{" "}
{requestTarget?.action === "MAINTENANCE"
? "and is set to MAINTENANCE — it stays out of the available pool until it clears."
: "immediately."}{" "}
The reason is required and shows in this train&apos;s History tab.
</Text>
<Textarea
label="Reason"
placeholder="Why must this wagon leave the scheduled consist? (required)"
placeholder={
requestTarget?.action === "MAINTENANCE"
? "Why is this wagon going to maintenance? (required)"
: "Why is this wagon leaving the consist? (required)"
}
value={requestReason}
onChange={(e) => setRequestReason(e.currentTarget.value)}
autosize
@@ -919,73 +779,36 @@ export default function TrainBuilderDetailPage() {
)
}
disabled={!requestReason.trim()}
loading={createDetachRequest.isPending}
loading={removeWagon.isPending || maintenanceWagon.isPending}
onClick={() =>
void withToast(async () => {
await createDetachRequest.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
action: requestTarget!.action,
reason: requestReason.trim(),
});
if (requestTarget!.action === "MAINTENANCE") {
await maintenanceWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
note: requestReason.trim(),
});
} else {
await removeWagon.mutateAsync({
id: composition.id,
wagonId: requestTarget!.wagon.id,
reason: requestReason.trim(),
});
}
toast({
title: `Request for wagon ${requestTarget!.wagon.wagonNumber} filed — awaiting approval`,
title: `Wagon ${requestTarget!.wagon.wagonNumber} ${
requestTarget!.action === "MAINTENANCE"
? "sent to maintenance"
: "detached"
}`,
});
closeRequest();
}, "Could not file the request")
}, "Could not detach the wagon")
}
>
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
{requestTarget?.action === "MAINTENANCE"
? "Send to maintenance"
: "Detach wagon"}
</Button>
</Group>
</Stack>