feat: enhance train scheduling and contract management features

- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage.
- Implemented API endpoints for recording station work and managing wagon detach requests.
- Updated contract templates to include Ethiopian customs handling options.
- Enhanced shipment forms to collect customs clearing agent details for without-customs bookings.
- Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts.
- Improved validation for customs clearing agent information in shipment forms.
- Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
Marshal
2026-08-25 21:44:21 +00:00
parent d5a5085d6d
commit b926a3116e
67 changed files with 2998 additions and 255 deletions

View File

@@ -5,18 +5,30 @@ import {
Group,
Pagination,
Paper,
Select,
Stack,
Table,
Text,
ThemeIcon,
Tooltip,
} from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import { Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import { ArrowRightLeft, Link2, MapPin, PackageOpen, User } from "lucide-react";
import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/services/api";
const parseError = (error: unknown, fallback: string) => {
if (isAxiosError(error)) {
const message = error.response?.data?.message;
if (Array.isArray(message)) return message.join(", ");
if (typeof message === "string") return message;
}
return fallback;
};
interface Props {
trainId: string;
/** Staff may attach and the train is editable (not out on a run). */
@@ -50,6 +62,47 @@ export default function DetachedWagonsPanel({
// Selection is page-scoped in the header checkbox but survives paging, so
// staff can gather wagons across pages into one attach.
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
// Attach the selection to a DIFFERENT built train: pick a target, reuse the
// same assign endpoint with that train's id. The builder attach is
// yard-agnostic, so any loose AVAILABLE wagon qualifies; a train that is
// out on a run rejects server-side and is disabled here too.
const { toast } = useToast();
const [targetTrainId, setTargetTrainId] = useState<string | null>(null);
const trainsQuery = useQuery(
api.trainBuilder.list.queryOptions({
input: { filters: { pageSize: 200, sortBy: "code", sortOrder: "ASC" } },
enabled: canAttach,
staleTime: 60_000,
}),
);
const trainOptions = (trainsQuery.data?.items ?? [])
.filter((t) => t.id !== trainId)
.map((t) => ({
value: t.id,
label: `${t.code}${t.trainName ? ` · ${t.trainName}` : ""}${t.wagonCount} wagon${t.wagonCount === 1 ? "" : "s"}${t.status === "IN_SERVICE" ? " (in service)" : ""}`,
disabled: t.status === "IN_SERVICE",
}));
const attachOther = useMutation(api.trainBuilder.assignWagons.mutationOptions());
const handleAttachOther = async () => {
if (!targetTrainId || !selected.size) return;
const target = trainsQuery.data?.items.find((t) => t.id === targetTrainId);
try {
await attachOther.mutateAsync({ id: targetTrainId, wagonIds: [...selected] });
toast({
title: `${selected.size} wagon(s) attached to ${target?.code ?? "the selected train"}`,
});
setSelected(new Set());
setTargetTrainId(null);
void query.refetch();
} catch (error) {
toast({
title: "Could not attach to the other train",
description: parseError(error, "The target train may be out on a run."),
variant: "destructive",
});
}
};
const allSelected = rows.length > 0 && rows.every((r) => selected.has(r.wagonId));
const toggle = (wagonId: string, checked: boolean) =>
@@ -79,17 +132,40 @@ export default function DetachedWagonsPanel({
</Stack>
</Group>
{canAttach ? (
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
<Group gap="sm" align="flex-end" wrap="wrap">
<Button
leftSection={<Link2 size={16} />}
disabled={selected.size === 0}
loading={attachPending}
onClick={() => {
onAttach([...selected]);
setSelected(new Set());
}}
>
Attach {selected.size || ""} wagon{selected.size === 1 ? "" : "s"}
</Button>
<Select
size="sm"
w={280}
searchable
clearable
placeholder="Or pick another train…"
maxDropdownHeight={350}
data={trainOptions}
value={targetTrainId}
onChange={setTargetTrainId}
nothingFoundMessage="No other built trains"
/>
<Button
variant="light"
leftSection={<ArrowRightLeft size={16} />}
disabled={selected.size === 0 || !targetTrainId}
loading={attachOther.isPending}
onClick={() => void handleAttachOther()}
>
Attach to that train
</Button>
</Group>
) : null}
</Group>