implement contract cancellation feature and update contract statuses

- Added functionality to cancel contracts, allowing users to provide a reason for cancellation.
- Updated contract statuses to include SUSPENDED and changed CLOSED to COMPLETED.
- Enhanced the UI to reflect the new cancellation option and updated messaging for contract statuses.
- Refactored contract booking actions to accommodate changes in booking logic for ONE_TIME and GENERAL contracts.
- Removed clearance document management from the contract detail page, as it is now handled per booking.
- Introduced a SQL script to reset bookings and train schedules for development purposes.
This commit is contained in:
Marshal
2026-07-28 05:02:58 +00:00
parent 481878e553
commit 2429f6b629
83 changed files with 3083 additions and 3729 deletions

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";