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

Freight feature/usermanagement
This commit is contained in:
marshal
2026-07-28 08:03:48 +03:00
committed by GitHub
90 changed files with 3132 additions and 3773 deletions

View File

@@ -1,13 +1,15 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Button, Modal, Stack, Text, Textarea } from "@mantine/core";
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import {
Check,
Eye,
FilePen,
FileSignature,
MessageSquareWarning,
PauseCircle,
PlayCircle,
ShieldCheck,
XCircle,
Zap,
@@ -43,6 +45,21 @@ const CLEARANCE_REVIEW_STATUSES = [
"CLEARANCE_READY_FOR_BOOKING",
];
/**
* Every step from the customer signature onward can be frozen. Mirrors
* SUSPENDABLE_CONTRACT_STATUSES on the API — the server is the authority, this
* list only decides whether the button is drawn.
*/
const SUSPENDABLE_STATUSES = [
"SIGNED_CUSTOMER",
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
"AWAITING_CLEARANCE_DOCUMENTS",
"CLEARANCE_UNDER_REVIEW",
"CLEARANCE_READY_FOR_BOOKING",
"ACTIVE_SHIPMENT_IN_PROGRESS",
];
/** Detail-page staff actions: accept / request changes / reject / generate / sign. */
export function ContractActionsToolbar({
contract,
@@ -62,6 +79,8 @@ export function ContractActionsToolbar({
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
// One key both ways — whoever can freeze a contract can unfreeze it.
const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
@@ -70,6 +89,10 @@ export function ContractActionsToolbar({
const [changesNote, setChangesNote] = useState("");
const [rejectOpen, setRejectOpen] = useState(false);
const [rejectReason, setRejectReason] = useState("");
const [suspendOpen, setSuspendOpen] = useState(false);
const [suspendReason, setSuspendReason] = useState("");
const [resumeOpen, setResumeOpen] = useState(false);
const [resumeNote, setResumeNote] = useState("");
// Whether the document is editable depends on WHO is viewing — only the
// approver whose turn it is may edit — so the server decides, not the client.
@@ -110,6 +133,86 @@ export function ContractActionsToolbar({
);
}
// Frozen: nothing on this contract moves — no new bookings, no progress on
// the shipments already under it — until the suspension is lifted, which
// returns the contract to the status it was suspended at.
if (status === "SUSPENDED") {
return (
<SectionCard icon={PauseCircle} title="Contract suspended">
<Stack gap="sm">
<Text size="sm" c="dimmed">
This contract is frozen. New bookings are blocked and its existing
shipments cannot progress.
{contract.statusBeforeSuspension
? ` Lifting the suspension returns it to ${contract.statusBeforeSuspension}.`
: ""}
</Text>
{contract.latestSuspensionNote && (
<Text size="sm">
<b>Reason:</b> {contract.latestSuspensionNote}
</Text>
)}
{maySuspend ? (
<Button
fullWidth
color="edr-green"
leftSection={<PlayCircle size={16} />}
onClick={() => setResumeOpen(true)}
>
Lift suspension
</Button>
) : (
<Text size="sm" c="dimmed">
You do not have permission to lift a suspension.
</Text>
)}
</Stack>
<Modal
opened={resumeOpen}
onClose={() => setResumeOpen(false)}
title="Lift suspension?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will return to{" "}
<b>{contract.statusBeforeSuspension ?? "CONTRACT_ACTIVE"}</b> and
the customer will be notified. Bookings on it resume immediately.
</Text>
<Textarea
label="Note (optional)"
placeholder="Why the suspension is being lifted…"
autosize
minRows={2}
value={resumeNote}
onChange={(e) => setResumeNote(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setResumeOpen(false)}>
Cancel
</Button>
<Button
color="edr-green"
loading={mutations.resume.isPending}
onClick={() =>
mutations.resume.mutate(resumeNote.trim() || undefined, {
onSuccess: () => {
setResumeOpen(false);
setResumeNote("");
},
})
}
>
Lift suspension
</Button>
</Group>
</Stack>
</Modal>
</SectionCard>
);
}
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
@@ -130,6 +233,7 @@ export function ContractActionsToolbar({
const clearanceReviewer = contract.customsClearingEnabled
? "Review clearance (GL)"
: "Review clearance (Ops)";
const canSuspend = maySuspend && SUSPENDABLE_STATUSES.includes(status);
return (
<SectionCard icon={Zap} title="Staff actions">
@@ -241,10 +345,23 @@ export function ContractActionsToolbar({
{/* GL "Create booking" removed for now — clearance ends at finalize and
the customer creates the booking in the portal. */}
{canSuspend && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<PauseCircle size={16} />}
onClick={() => setSuspendOpen(true)}
>
Suspend contract
</Button>
)}
{!canAccept &&
!inApproval &&
!canViewContract &&
!canReviewClearance && (
!canReviewClearance &&
!canSuspend && (
<Text size="sm" c="dimmed">
No staff actions available for this status. Monitor until the
workflow advances.
@@ -315,6 +432,51 @@ export function ContractActionsToolbar({
</Stack>
</Modal>
{/* Suspend — freezes the contract AND every shipment under it */}
<Modal
opened={suspendOpen}
onClose={() => setSuspendOpen(false)}
title="Suspend this contract?"
centered
>
<Stack gap="md">
<Text size="sm">
Contract <b>{contract.reference}</b> will be frozen at its current
step (<b>{status}</b>). No new shipments can be booked and the
shipments already under it stop moving until the suspension is
lifted. The customer is notified.
</Text>
<Textarea
label="Reason for suspension"
placeholder="Explain why this contract is being suspended…"
autosize
minRows={3}
value={suspendReason}
onChange={(e) => setSuspendReason(e.currentTarget.value)}
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setSuspendOpen(false)}>
Cancel
</Button>
<Button
color="orange"
disabled={!suspendReason.trim()}
loading={mutations.suspend.isPending}
onClick={() =>
mutations.suspend.mutate(suspendReason, {
onSuccess: () => {
setSuspendOpen(false);
setSuspendReason("");
},
})
}
>
Suspend contract
</Button>
</Group>
</Stack>
</Modal>
{/* Reject */}
<Modal
opened={rejectOpen}

View File

@@ -31,6 +31,17 @@ const isHazardStep = (requiredRole: string): boolean =>
const roleLabel = (requiredRole: string): string =>
CONTRACT_APPROVAL_ROLE_LABELS[requiredRole] ?? requiredRole;
/** When the approver acted — "27 Jul 2026, 18:18". */
const fmtActedAt = (iso: string): string =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
type Mutations = ReturnType<typeof useContractMutations>;
interface ContractApprovalStepsCardProps {
@@ -349,6 +360,10 @@ function StepRow({
? "edr-green"
: "gray";
const hazard = isHazardStep(step.requiredRole);
// A send-back wipes acted_at with the status, so a re-opened step shows no
// stale timestamp.
const acted =
step.actedAt && step.status !== "PENDING" ? fmtActedAt(step.actedAt) : null;
return (
<Group
@@ -412,6 +427,14 @@ function StepRow({
</Badge>
)}
</Group>
{/* Decided steps carry their verdict time — the chain doubles as an
audit trail, so "who was waiting on whom, and for how long" has to
be readable without opening the revision history. */}
{acted && (
<Text size="xs" c="dimmed" truncate>
{step.status === "REJECTED" ? "Rejected" : "Approved"} {acted}
</Text>
)}
{step.note && (
<Text size="xs" c="dimmed" truncate>
{step.note}

View File

@@ -37,6 +37,13 @@ function newArticleId(): string {
return `art-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
}
/** Midnight today — the earliest day a contract's validity may start. */
function startOfToday(): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
}
interface EditableArticle {
id: string;
title: string;
@@ -118,6 +125,14 @@ export function ContractDocumentEditorModal({
);
}, [opened, draft]);
// Accept mode opens on today — a contract never starts in the past, and the
// pickers below refuse earlier days.
useEffect(() => {
if (!opened || mode !== "accept") return;
setValidityStart(startOfToday());
setValidityEnd(null);
}, [opened, mode]);
// Default validity to the first configured option (accept mode).
// useEffect(() => {
// if (mode === "accept" && !validityDays && validityOptions.length > 0) {
@@ -419,6 +434,7 @@ export function ContractDocumentEditorModal({
placeholder="Contract validity start"
value={validityStart}
onChange={(v) => setValidityStart(v ? new Date(v) : null)}
minDate={startOfToday()}
maxDate={validityEnd ?? undefined}
clearable
/>
@@ -427,7 +443,7 @@ export function ContractDocumentEditorModal({
placeholder="Contract validity end"
value={validityEnd}
onChange={(v) => setValidityEnd(v ? new Date(v) : null)}
minDate={validityStart ?? undefined}
minDate={validityStart ?? startOfToday()}
clearable
/>
</Group>

View File

@@ -1018,14 +1018,9 @@ export default function GlCreateBookingForm() {
// Non-fatal
}
}
if (contract.contractKind === "GENERAL") {
// GENERAL per-booking clearance: land on the booking's clearance
// detail — the same page the Shipments tab on the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
} else {
// ONE_TIME customs keeps its clearance on the contract.
navigate(`/dashboard/contracts/clearance/${contract.id}`);
}
// Clearance is always per booking — land on that booking's clearance
// detail, the same page the hub opens.
navigate(`/dashboard/clearance/${booking.id}`);
},
});
};
@@ -1067,7 +1062,13 @@ export default function GlCreateBookingForm() {
variant="default"
radius="md"
leftSection={<ChevronLeft size={16} />}
onClick={() => navigate(`/dashboard/contracts/clearance/${contract.id}`)}
onClick={() =>
navigate(
completeBookingId
? `/dashboard/clearance/${completeBookingId}`
: "/dashboard/contracts/clearance",
)
}
>
Back to clearance
</Button>

View File

@@ -32,6 +32,11 @@ const KIND_META: Record<string, { label: string; color: string; icon: ReactNode
color: "orange",
icon: <Wrench size={14} />,
},
MAINTENANCE: {
label: "Sent to maintenance",
color: "red",
icon: <Wrench size={14} />,
},
};
const yardLabel = (
@@ -103,10 +108,16 @@ const WagonMovementHistoryModal = ({
<Text size="sm" fw={600}>
{from}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
{/* Status events (maintenance) sit in one yard — an arrow
pointing at the same yard reads as a broken row. */}
{movement.fromYardId !== movement.toYardId && (
<>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{to}
</Text>
</>
)}
<Badge size="xs" variant="light" color={meta.color}>
{meta.label}
</Badge>

View File

@@ -30,7 +30,7 @@ const parseError = (error: unknown, fallback: string) => {
/**
* Step one of the Train Builder: pick the yard it is being assembled in and
* couple at least two locomotives from that yard. The train code is assigned by
* couple at least one locomotive from that yard. The train code is assigned by
* the system. Wagons are attached afterwards on the composition page.
*/
export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrainModalProps) {
@@ -86,9 +86,9 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
}, [opened]);
const handleBuild = async () => {
if (!yardId || locomotiveIds.length < 2) {
if (!yardId || locomotiveIds.length < 1) {
toast({
title: "Pick a yard and couple at least two locomotives",
title: "Pick a yard and couple at least one locomotive",
variant: "destructive",
});
return;
@@ -185,17 +185,15 @@ export default function BuildTrainModal({ opened, onClose, onBuilt }: BuildTrain
/>
<MultiSelect
label="Locomotives"
description="A train must be pulled by at least two locomotives (front and back). First pick becomes the lead."
placeholder={yardId ? "Select at least two locomotives" : "Select a yard first"}
description="A train must be pulled by at least one locomotive. First pick becomes the lead."
placeholder={yardId ? "Select at least one locomotive" : "Select a yard first"}
data={locomotiveOptions}
value={locomotiveIds}
onChange={setLocomotiveIds}
searchable
disabled={!yardId}
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
}
nothingFoundMessage={
yardId ? "No available locomotives in this yard" : "Select a yard first"

View File

@@ -16,7 +16,7 @@ const parseError = (error: unknown, fallback: string) => {
return fallback;
};
/** Swap the locomotive set of a built train (minimum 2, same-yard rule). */
/** Swap the locomotive set of a built train (minimum 1, same-yard rule). */
export default function ChangeLocomotivesModal({
composition,
opened,
@@ -74,8 +74,8 @@ export default function ChangeLocomotivesModal({
const handleSave = async () => {
if (!composition) return;
if (locomotiveIds.length < 2) {
toast({ title: "A train needs at least two locomotives", variant: "destructive" });
if (locomotiveIds.length < 1) {
toast({ title: "A train needs at least one locomotive", variant: "destructive" });
return;
}
try {
@@ -112,9 +112,7 @@ export default function ChangeLocomotivesModal({
onChange={setLocomotiveIds}
searchable
error={
locomotiveIds.length > 0 && locomotiveIds.length < 2
? "Select at least two locomotives"
: undefined
locomotiveIds.length < 1 ? "Select at least one locomotive" : undefined
}
nothingFoundMessage="No available locomotives in this yard"
/>

View File

@@ -12,6 +12,7 @@ import { type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { TrainCompositionWagon } from "@/services/trainBuilder.service";
import { wagonTypeColor } from "./trainStatus";
/** Reparent dragged row to body — fixes position:fixed inside transformed parents. */
const PortalAwareRow = ({
@@ -58,11 +59,36 @@ export default function ConsistWagonList({
);
}
// Legend of the types actually coupled, in consist order — the colour code is
// only readable if the row tints are keyed somewhere.
const legend = [
...new Map(
wagons
.filter((w) => w.wagonType)
.map((w) => [w.wagonType!.code, w.wagonType!]),
).values(),
];
return (
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="train-consist-wagons" isDropDisabled={!editable || busy}>
{(dropProvided) => (
<Stack gap="xs" ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
{legend.length > 1 ? (
<Group gap={6} wrap="wrap">
{legend.map((type) => (
<Badge
key={type.code}
size="sm"
radius="sm"
variant="light"
color={wagonTypeColor(type.code)}
>
{type.code} · {type.name}
</Badge>
))}
</Group>
) : null}
{wagons.map((wagon, index) => (
<Draggable
key={wagon.id}
@@ -97,8 +123,8 @@ export interface ConsistWagonListProps {
editable: boolean;
onReorder: (wagonIds: string[]) => void;
onRemove: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status. */
onMaintenance: (wagonId: string) => void;
/** Detach the wagon and move it to MAINTENANCE status (page confirms first). */
onMaintenance: (wagon: TrainCompositionWagon) => void;
busy?: boolean;
}
@@ -119,8 +145,10 @@ function WagonRow({
editable: boolean;
busy: boolean;
onRemove: (wagonId: string) => void;
onMaintenance: (wagonId: string) => void;
onMaintenance: (wagon: TrainCompositionWagon) => void;
}) {
const color = wagonTypeColor(wagon.wagonType?.code);
return (
<PortalAwareRow snapshot={snapshot}>
<Group
@@ -132,9 +160,14 @@ function WagonRow({
p="sm"
style={{
...dragProvided.draggableProps.style,
border: "1px solid var(--mantine-color-gray-3)",
border: `1px solid var(--mantine-color-${color}-2)`,
borderLeft: `4px solid var(--mantine-color-${color}-5)`,
borderRadius: "var(--mantine-radius-md)",
background: snapshot.isDragging ? "var(--mantine-color-gray-0)" : "white",
// Tinted by wagon type so a mixed consist is scannable at a glance;
// the drag state keeps its own neutral lift.
background: snapshot.isDragging
? "white"
: `var(--mantine-color-${color}-0)`,
boxShadow: snapshot.isDragging ? "0 8px 24px rgba(0, 0, 0, 0.12)" : undefined,
cursor: editable ? (snapshot.isDragging ? "grabbing" : "grab") : "default",
userSelect: "none",
@@ -145,13 +178,20 @@ function WagonRow({
<GripVertical size={18} />
</Box>
) : null}
<Badge variant="light" color="gray" size="sm">
<Badge variant="filled" color={color} size="sm">
{index + 1}
</Badge>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600} ff="monospace" truncate>
{wagon.wagonNumber}
</Text>
{wagon.wagonType ? (
<Badge variant="light" color={color} size="xs" radius="sm">
{wagon.wagonType.code}
</Badge>
) : null}
</Group>
<Text size="xs" c="dimmed" truncate>
{wagon.wagonType
? `${wagon.wagonType.name} · ${wagon.wagonType.capacityTons}T cap · ${wagon.wagonType.lengthMeters}m`
@@ -165,7 +205,7 @@ function WagonRow({
variant="subtle"
color="orange"
disabled={busy}
onClick={() => onMaintenance(wagon.id)}
onClick={() => onMaintenance(wagon)}
aria-label={`Send wagon ${wagon.wagonNumber} to maintenance`}
>
<Wrench size={16} />

View File

@@ -56,6 +56,58 @@ export const locomotiveStatusLabel = (status: string): string =>
.replace(/_/g, " ")
.replace(/^\w/, (c) => c.toUpperCase());
/**
* Hues for the wagon-type color code. No red or gray — red reads as a fault on
* a consist row, gray is the "unknown type" fallback.
*/
const WAGON_TYPE_COLORS = [
"blue",
"teal",
"grape",
"orange",
"cyan",
"indigo",
"pink",
"lime",
"violet",
"yellow",
];
/**
* Fixed hue per seeded wagon-type code. Related families sit on neighbouring
* hues (gondolas blue/indigo, hoppers grape/violet, flats teal/lime) so a
* consist reads as groups, not confetti. Explicit rather than hashed because
* hashing 10 codes into 10 hues collides — and two types sharing a colour is
* exactly what a colour code must not do.
*/
const WAGON_TYPE_CODE_COLORS: Record<string, string> = {
CW3: "blue", // Gondola open
CW4: "indigo", // Gondola covered
KW2: "grape", // Hopper covered
KW3: "violet", // Hopper open
NW5: "teal", // Flat
NW6: "lime", // Flat (long)
NW7: "pink", // Double deck sedan
BW1: "cyan", // Refrigerated
GW2: "orange", // Tank
PW2: "yellow", // Box
};
/**
* Stable hue per wagon-type code. Unseeded codes fall back to a hash so a new
* type still gets a consistent colour instead of collapsing to gray.
*/
export const wagonTypeColor = (code?: string | null): string => {
if (!code) return "gray";
const seeded = WAGON_TYPE_CODE_COLORS[code];
if (seeded) return seeded;
let hash = 0;
for (let i = 0; i < code.length; i++) {
hash = (hash * 31 + code.charCodeAt(i)) >>> 0;
}
return WAGON_TYPE_COLORS[hash % WAGON_TYPE_COLORS.length]!;
};
/** Badge color per trade direction (Mantine palette keys). */
export const directionColor = (direction?: string | null): string =>
direction === "IMPORT" ? "blue" : direction === "EXPORT" ? "orange" : "gray";