mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
Merge pull request #982 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user