enhance contract and train scheduling features

- Added pricing service integration to ContractsService for pre-persistence contract pricing.
- Updated LegCapacityPanel to display cargo weight instead of gross weight for better clarity on booked cargo.
- Enhanced train scheduling service to include cargo weight in booking details.
- Modified ClearanceDocumentsPage to include FULLY_EXECUTED status in booking status options.
- Refactored FileUploadSettingsPage to categorize file upload settings into tabs for better organization.
- Removed legacy onboarding fields and settings from file upload settings seeder.
- Improved type definitions for train scheduling to include cargo weight without wagon tare.
This commit is contained in:
Marshal
2026-08-01 05:37:46 +00:00
parent 6a4067ec42
commit a8788eb549
12 changed files with 511 additions and 463 deletions

View File

@@ -105,7 +105,10 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
bookingId: b.id,
reference: b.reference ?? b.id,
wagons: Number(b.wagonsRequired) || 0,
grossTons: round1(Number(b.weightTons) || 0),
// The booking's OWN weight (cargo VGM/bulk tons) — no wagon tare, so
// each row shows exactly what the customer booked and the leg total
// is the plain sum of its rows.
grossTons: round1(Number(b.cargoWeightTons ?? b.weightTons) || 0),
route: b.origin && b.destination ? `${b.origin}${b.destination}` : null,
}))
.sort((a, b) => b.grossTons - a.grossTons);
@@ -164,9 +167,10 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Stack gap={2}>
<Text fw={700}>Per-leg utilization</Text>
<Text size="sm" c="dimmed">
Each adjacent leg lists every booking riding it a through
booking counts on all its legs. Totals are checked against the
train limits{weightCap != null ? ` (${weightCap}T incl. tolerance` : ""}
Each adjacent leg lists every booking riding it with its own booked
cargo weight a through booking counts on all its legs, and the
leg total is the plain sum of its rows. Checked against the train
limits{weightCap != null ? ` (${weightCap}T pull` : ""}
{weightCap != null && wagonCap != null ? `, ${wagonCap} wagons` : ""}
{weightCap != null ? ")" : ""}.
</Text>
@@ -178,7 +182,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Table.Th style={{ width: 28 }} />
<Table.Th>Leg</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Gross weight</Table.Th>
<Table.Th>Cargo weight</Table.Th>
<Table.Th>Bookings</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>

View File

@@ -40,15 +40,21 @@ import {
const PAGE_SIZE = 10;
/** Status filter options (values = `statuses` param). */
/**
* Status filter options (values = `statuses` param). FULLY_EXECUTED is the
* post-approval status of intercity (domestic) bookings — kept in the list as
* history, otherwise an approved intercity row vanishes from the hub.
*/
const BOOKING_STATUS_OPTIONS = [
{
value: "AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY",
value:
"AWAITING_DOCUMENTS,DOCUMENTS_UNDER_REVIEW,CLEARANCE_READY,FULLY_EXECUTED",
label: "All statuses",
},
{ value: "AWAITING_DOCUMENTS", label: "Awaiting documents" },
{ value: "DOCUMENTS_UNDER_REVIEW", label: "Under review" },
{ value: "CLEARANCE_READY", label: "Clearance ready" },
{ value: "FULLY_EXECUTED", label: "Approved (history)" },
];
const TRADE_DIRECTION_OPTIONS = [

View File

@@ -7,31 +7,46 @@ import {
Card,
Code,
Group,
SimpleGrid,
Stack,
Table,
Tabs,
Text,
TextInput,
ThemeIcon,
} from "@mantine/core";
import {
ArrowDownToLine,
ArrowUpFromLine,
FileUp,
FolderOpen,
HardDrive,
Inbox,
Layers,
Paperclip,
Search,
Settings,
TrainFront,
X,
} from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { KpiStrip, PageContainer, PageHeader } from "@/components/page";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { api } from "@/services/api";
import { getMinFiles, type FileUploadSetting } from "@/types/fileUploadSettings";
import { DataTable, type ColumnDef } from "@edr/ui-common";
import ManageFileUploadFieldsDialog from "./ManageFileUploadFieldsDialog";
type DocCategory = "import" | "export" | "intercity" | "other";
function categorize(setting: FileUploadSetting): DocCategory {
const code = setting.code.toLowerCase();
if (code.includes("intercity")) return "intercity";
if (code.includes("export")) return "export";
if (code.includes("import")) return "import";
return "other";
}
export default function FileUploadSettingsPage() {
const [query, setQuery] = useState("");
@@ -60,6 +75,17 @@ export default function FileUploadSettingsPage() {
);
}, [fileUploadSettings, query]);
const byCategory = useMemo(() => {
const groups: Record<DocCategory, FileUploadSetting[]> = {
import: [],
export: [],
intercity: [],
other: [],
};
for (const s of filtered) groups[categorize(s)].push(s);
return groups;
}, [filtered]);
const totalFields = fileUploadSettings.reduce(
(sum, s) => sum + s.fields.length,
0,
@@ -73,128 +99,7 @@ export default function FileUploadSettingsPage() {
0,
);
const columns = useMemo<ColumnDef<FileUploadSetting>[]>(() => {
const headerClassName = ruleEngineTable.headerCell;
const cellClassName = ruleEngineTable.bodyCell;
return [
{
id: "setting",
header: "Setting",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const s = row.original;
return (
<Group gap="sm" wrap="nowrap">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<FileUp size={18} />
</ThemeIcon>
<Stack gap={0} style={{ minWidth: 0, maxWidth: 260 }}>
<Text size="sm" fw={600} lh={1.2} truncate>
{s.label}
</Text>
<Text size="xs" c="dimmed" truncate>
{s.description ?? "No description"}
</Text>
</Stack>
</Group>
);
},
},
{
id: "code",
header: "Code",
meta: { headerClassName, cellClassName },
cell: ({ row }) => <Code>{row.original.code}</Code>,
},
{
id: "entity",
header: "Entity",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Badge variant="light" color="gray" tt="capitalize">
{row.original.entity ?? "—"}
</Badge>
),
},
{
id: "fields",
header: "Fields",
meta: { headerClassName, cellClassName },
cell: ({ row }) => (
<Group gap={6} wrap="nowrap">
<Paperclip size={15} color="var(--mantine-color-edr-green-6)" />
<Text size="sm" fw={500}>
{row.original.fields.length}
</Text>
</Group>
),
},
{
id: "requiredMulti",
header: "Required / Multi",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const required = row.original.fields.filter((f) => f.isRequired).length;
const multi = row.original.fields.filter((f) => f.isMultiple).length;
return (
<Group gap={6} wrap="nowrap">
<Badge variant="light" color="edr-green">
{required} required
</Badge>
<Badge variant="light" color="gray">
{multi} multi
</Badge>
</Group>
);
},
},
{
id: "maxSize",
header: "Max size",
meta: { headerClassName, cellClassName },
cell: ({ row }) => {
const maxSize = Math.max(
0,
...row.original.fields.map((f) => f.maxSizeMb),
);
return (
<Group gap={6} wrap="nowrap">
<HardDrive size={15} color="var(--mantine-color-gray-5)" />
<Text size="sm">{maxSize ? `${maxSize} MB` : "—"}</Text>
</Group>
);
},
},
{
id: "actions",
header: "Actions",
meta: {
headerClassName,
cellClassName: `${cellClassName} whitespace-nowrap`,
},
// Settings are seeded/fixed — staff may only update a setting's fields,
// not create, edit, or delete the settings themselves.
cell: ({ row }) => {
const setting = row.original;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ManageFileUploadFieldsDialog setting={setting}>
<Button
variant="default"
size="xs"
leftSection={<Paperclip size={14} />}
>
Fields
</Button>
</ManageFileUploadFieldsDialog>
</Group>
);
},
},
];
}, []);
const tableStatus = isLoading ? "loading" : isError ? "error" : "success";
const gridStatus = isLoading ? "loading" : isError ? "error" : "success";
return (
<PageContainer>
@@ -214,54 +119,98 @@ export default function FileUploadSettingsPage() {
/>
<Card p={0}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<TextInput
placeholder="Search by code, label, or file key…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
variant="transparent"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={16} />
</ActionIcon>
) : null
}
style={{ maxWidth: 420 }}
/>
<Tabs defaultValue="import" variant="outline" radius="md">
<Box px="md" pt="md">
<Tabs.List>
<Tabs.Tab value="import" leftSection={<ArrowDownToLine size={16} />}>
Import
</Tabs.Tab>
<Tabs.Tab value="export" leftSection={<ArrowUpFromLine size={16} />}>
Export
</Tabs.Tab>
<Tabs.Tab value="intercity" leftSection={<TrainFront size={16} />}>
Intercity
</Tabs.Tab>
<Tabs.Tab value="other" leftSection={<FolderOpen size={16} />}>
Other
</Tabs.Tab>
</Tabs.List>
</Box>
<Box style={{ overflowX: "auto" }} w="100%">
<DataTable
columns={columns}
data={filtered}
status={tableStatus}
error={
isError
? {
message: "Failed to load settings.",
description:
error instanceof Error ? error.message : "Unknown error.",
onRetry: () => void refetch(),
{(["import", "export", "intercity", "other"] as const).map((tab) => (
<Tabs.Panel key={tab} value={tab}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<TextInput
placeholder="Search by code, label, or file key…"
leftSection={<Search size={18} />}
value={query}
onChange={(e) => setQuery(e.currentTarget.value)}
rightSection={
query ? (
<ActionIcon
size="sm"
color="gray"
variant="transparent"
onClick={() => setQuery("")}
aria-label="Clear search"
>
<X size={16} />
</ActionIcon>
) : null
}
: undefined
}
emptyMessage={
query.trim()
? "No file upload settings match your search."
: "No file upload settings configured."
}
containerClassName="border-0 shadow-none bg-transparent min-w-[920px]"
/>
</Box>
</Stack>
style={{ maxWidth: 420 }}
/>
</Box>
<Box px="md" pb="md" w="100%">
{gridStatus === "error" ? (
<Card withBorder p="lg">
<Stack gap="xs" align="center" ta="center">
<Text fw={600} c="red">
Failed to load settings.
</Text>
<Text size="sm" c="dimmed">
{error instanceof Error ? error.message : "Unknown error."}
</Text>
<Button
variant="light"
size="xs"
onClick={() => void refetch()}
>
Retry
</Button>
</Stack>
</Card>
) : gridStatus === "loading" ? (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} withBorder p="md" h={168} />
))}
</SimpleGrid>
) : byCategory[tab].length === 0 ? (
<Card withBorder p="xl">
<Stack gap={4} align="center" ta="center" c="dimmed">
<Inbox size={28} />
<Text size="sm">
{query.trim()
? "No file upload settings match your search."
: "No file upload settings in this category."}
</Text>
</Stack>
</Card>
) : (
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
{byCategory[tab].map((setting) => (
<SettingCard key={setting.id} setting={setting} />
))}
</SimpleGrid>
)}
</Box>
</Stack>
</Tabs.Panel>
))}
</Tabs>
</Card>
<BehaviorReferenceCard />
@@ -269,6 +218,72 @@ export default function FileUploadSettingsPage() {
);
}
function SettingCard({ setting }: { setting: FileUploadSetting }) {
const required = setting.fields.filter((f) => f.isRequired).length;
const multi = setting.fields.filter((f) => f.isMultiple).length;
const maxSize = Math.max(0, ...setting.fields.map((f) => f.maxSizeMb));
return (
<Card withBorder radius="md" p="md" h="100%">
<Stack gap="sm" h="100%">
<Group gap="sm" wrap="nowrap" align="flex-start">
<ThemeIcon size={40} radius="md" variant="light" color="edr-green">
<FileUp size={18} />
</ThemeIcon>
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
<Text size="sm" fw={600} lh={1.25}>
{setting.label}
</Text>
<Text size="xs" c="dimmed" lineClamp={2}>
{setting.description ?? "No description"}
</Text>
</Stack>
</Group>
<Group gap={6} wrap="wrap">
<Badge variant="light" color="gray" tt="capitalize">
{setting.entity ?? "—"}
</Badge>
<Badge variant="light" color="edr-green">
{setting.fields.length} field{setting.fields.length === 1 ? "" : "s"}
</Badge>
{required > 0 && (
<Badge variant="light" color="orange">
{required} required
</Badge>
)}
{multi > 0 && (
<Badge variant="light" color="blue">
{multi} multi
</Badge>
)}
</Group>
<Group gap={6} wrap="nowrap">
<Code style={{ flex: 1, minWidth: 0 }}>
<Text truncate span size="xs">
{setting.code}
</Text>
</Code>
</Group>
<Group gap={6} c="dimmed">
<HardDrive size={14} />
<Text size="xs">{maxSize ? `Max ${maxSize} MB per file` : "No size limit set"}</Text>
</Group>
<Group justify="flex-end" mt="auto">
<ManageFileUploadFieldsDialog setting={setting}>
<Button variant="default" size="xs" leftSection={<Paperclip size={14} />}>
Fields
</Button>
</ManageFileUploadFieldsDialog>
</Group>
</Stack>
</Card>
);
}
/**
* Static reference: how `min_files` / `max_files` are derived from the
* Required × Multiple toggles. Documentation aid for whoever wires uploaders.

View File

@@ -676,6 +676,8 @@ export interface TrainScheduleDetail {
reference: string | null;
customer: string | null;
weightTons: number;
/** Cargo only (VGM/bulk tons) — the booked weight without wagon tare. */
cargoWeightTons?: number;
status: string | null;
schedulingStatus?: SchedulingStatus | null;
freightType?: FreightType | string | null;