Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat

This commit is contained in:
marshal
2026-06-29 12:31:21 +03:00
46 changed files with 2665 additions and 992 deletions

View File

@@ -135,6 +135,7 @@ export const URL_CONSTANTS = {
TRAIN_SCHEDULING: {
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
AVAILABLE_DAYS_FOR_CARGO: "/api/train-scheduling/available-days-for-cargo",
},
PAYMENTS: {

View File

@@ -1,23 +1,5 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameMonth,
isToday,
startOfMonth,
startOfWeek,
} from "date-fns";
import {
Calendar as CalendarIcon,
Check,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { useMemo, useState } from "react";
import { OperationDatePicker as DatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
@@ -29,11 +11,9 @@ interface OperationDatePickerProps {
}
/**
* Compact month calendar for picking the binding shipment day at the
* operation-request step. Only days that have an OPEN scheduled departure on the
* booking route are selectable; all other days are disabled.
*
* Shared by the booking detail clearance card and the home-page action modal.
* Route-based day picker for the operation-request step: a thin query wrapper
* around the shared presentational `OperationDatePicker` from `@edr/ui-common`.
* Only days with an OPEN scheduled departure on the route are selectable.
*/
export function OperationDatePicker({
originYardId,
@@ -41,8 +21,6 @@ export function OperationDatePicker({
value,
onChange,
}: OperationDatePickerProps) {
const [month, setMonth] = useState(() => startOfMonth(new Date()));
const { data: availableDays, isLoading } = useQuery(
api.bookings.getAvailableDays.queryOptions({
input: { originYardId, destinationYardId },
@@ -50,170 +28,14 @@ export function OperationDatePicker({
}),
);
const departureDays = useMemo(
() => new Set(availableDays ?? []),
[availableDays],
);
const cells = useMemo(() => {
const start = startOfWeek(startOfMonth(month), { weekStartsOn: 1 });
const end = endOfWeek(endOfMonth(month), { weekStartsOn: 1 });
return eachDayOfInterval({ start, end }).map((date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date,
dateString,
day: date.getDate(),
inMonth: isSameMonth(date, month),
today: isToday(date),
selected: value === dateString,
hasDeparture: departureDays.has(dateString),
};
});
}, [month, departureDays, value]);
return (
<Box
style={{
border: "1px solid #E6ECF2",
borderRadius: 12,
padding: 14,
maxWidth: 340,
}}
>
<Group justify="space-between" align="center" mb="sm">
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, -1))}
>
<ChevronLeft size={15} />
</Button>
<Text fz="13px" fw={700} c="#10202F">
{format(month, "MMMM yyyy")}
</Text>
<Button
variant="default"
size="xs"
px={6}
radius="xl"
onClick={() => setMonth((m) => addMonths(m, 1))}
>
<ChevronRight size={15} />
</Button>
</Group>
{isLoading ? (
<Group justify="center" py="md" gap={8}>
<CalendarIcon size={15} color="#9AA8B5" />
<Text fz="12px" c="dimmed">
Loading available days
</Text>
</Group>
) : (
<>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
marginBottom: 6,
}}
>
{["M", "T", "W", "T", "F", "S", "S"].map((d, i) => (
<Text key={i} ta="center" fz="10px" fw={700} c="#9AA8B5">
{d}
</Text>
))}
</Box>
<Box
style={{
display: "grid",
gridTemplateColumns: "repeat(7, 1fr)",
gap: 4,
}}
>
{cells.map((c) => {
const clickable = c.hasDeparture && c.inMonth;
return (
<button
key={c.dateString}
type="button"
disabled={!clickable}
onClick={() => clickable && onChange(c.dateString)}
style={{
position: "relative",
height: 34,
borderRadius: 8,
fontSize: 12.5,
fontWeight: c.selected ? 800 : 600,
cursor: clickable ? "pointer" : "default",
border: c.selected
? "1.5px solid #12B981"
: clickable
? "1px solid #CDEBDD"
: "1px solid transparent",
background: c.selected
? "#12B981"
: clickable
? "#F4FBF7"
: "transparent",
color: c.selected
? "#fff"
: !c.inMonth
? "#CBD5E1"
: clickable
? "#0A6F4D"
: "#C4CDD6",
transition: "all 120ms ease",
}}
>
{c.day}
{c.hasDeparture && c.inMonth && !c.selected && (
<span
style={{
position: "absolute",
bottom: 4,
left: "50%",
transform: "translateX(-50%)",
width: 4,
height: 4,
borderRadius: "50%",
background: "#12B981",
}}
/>
)}
{c.selected && (
<Check
size={11}
color="#fff"
strokeWidth={3}
style={{
position: "absolute",
bottom: 3,
left: "50%",
transform: "translateX(-50%)",
}}
/>
)}
</button>
);
})}
</Box>
{value && (
<Text fz="12px" c="#0A6F4D" fw={600} mt="sm">
Selected: {format(new Date(value + "T00:00:00"), "EEE, MMM d yyyy")}
</Text>
)}
{!isLoading && departureDays.size === 0 && (
<Text fz="12px" c="orange.7" mt="sm">
No scheduled departures found for this route yet.
</Text>
)}
</>
)}
</Box>
<DatePicker
availableDays={availableDays ?? []}
isLoading={isLoading}
value={value}
onChange={onChange}
/>
);
}
export default OperationDatePicker;

View File

@@ -285,13 +285,12 @@ export default function NewContractPage() {
const isContainer = data.cargoType === "container";
// Cargo scope rows — no quantities (doc §5.4). Container: one row per enabled
// size (+ optional commodity); bulk: a single commodity row.
// size; bulk: a single commodity row.
// GENERAL contracts carry a quantity cap (draw-down); ONE_TIME does not.
const isGeneral = data.contractKind === "general_contract";
const cargoScope: Freight.CreateContractCargoScopeDto[] = isContainer
? data.enabledContainerSizes.map((size) => ({
containerSize: size,
cargoTypeId: data.cargoCommodityId || undefined,
quantityCap:
isGeneral && data.containerSizeCaps[size]
? data.containerSizeCaps[size]

View File

@@ -32,8 +32,8 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/services/api";
import { OperationDatePicker } from "@/pages/bookings/clearance/OperationDatePicker";
import {
SelectField,
StepCard,
@@ -264,8 +264,8 @@ export default function NewShipmentPage() {
{/* Single-step form — all sections on one page. */}
<Stack gap="lg" className="mx-auto max-w-4xl">
<RouteStep form={form} contract={contract} routes={routes} />
<ScheduleStep form={form} contract={contract} routes={routes} />
<CargoStep form={form} contract={contract} />
<ScheduleStep form={form} contract={contract} routes={routes} />
<NotesSection form={form} />
</Stack>
</Box>
@@ -486,6 +486,7 @@ function RouteStep({
function ScheduleStep({
form,
contract,
routes,
}: {
form: ShipmentForm;
@@ -494,35 +495,96 @@ function ScheduleStep({
}) {
const contractRouteId = form.watch("contractRouteId");
const route = routes.find((r) => r.id === contractRouteId) ?? routes[0];
// Read the cargo entered in the previous step so the day list reflects what
// can actually be shipped (matching wagons + open train capacity).
const isContainer = contract.freightType === "CONTAINER";
const containerLines = form.watch("containers");
const cargoWeightTons = form.watch("cargoWeightTons");
const itemCount = form.watch("itemCount");
const cargoQuery = useMemo<Freight.AvailableDaysForCargoQuery | null>(() => {
if (!route?.originYardId || !route?.destinationYardId) return null;
if (isContainer) {
const containers = (containerLines ?? [])
.map((l) => ({
containerSize: l.containerSize,
quantity: Number(l.quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length === 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "CONTAINER",
containers,
};
}
const tons = Number(cargoWeightTons || 0);
if (tons <= 0) return null;
return {
originYardId: route.originYardId,
destinationYardId: route.destinationYardId,
freightType: "BULK",
cargoTypeCode:
contract.pricingBreakdown?.lineItems?.find((li) => li.cargoTypeCode)
?.cargoTypeCode ?? undefined,
totalWeightTons: tons,
};
// itemCount is referenced so the query refreshes when a PER_ITEM cargo
// amount changes (weight is the sizing input the backend uses).
}, [
route,
isContainer,
containerLines,
cargoWeightTons,
itemCount,
contract.pricingBreakdown,
]);
const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}),
enabled: cargoQuery !== null,
});
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Pick the binding shipment day. Only days with an open departure on your route can be selected."
description="Pick the binding shipment day. Only days with an open train that has enough matching wagons for your cargo can be selected."
/>
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10}>
<OperationDatePicker
originYardId={route?.originYardId}
destinationYardId={route?.destinationYardId}
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
/>
{cargoQuery === null ? (
<Alert color="yellow" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Enter your cargo details first available shipment days depend on the
wagons your cargo needs.
</Alert>
) : (
<Controller
name="scheduledDate"
control={form.control}
render={({ field, fieldState }) => (
<Box>
<StepLabel>Shipment day *</StepLabel>
<Box mt={10}>
<OperationDatePicker
availableDays={availableDays ?? []}
isLoading={isLoading}
value={field.value ?? ""}
onChange={(d) => field.onChange(d)}
/>
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
{fieldState.error?.message && (
<Text fz="xs" c="red" mt={6}>
{fieldState.error.message}
</Text>
)}
</Box>
)}
/>
)}
/>
)}
</StepCard>
);
}

View File

@@ -158,8 +158,6 @@ export const contractFormSchema = z
// Container scope: the enabled sizes (min 1). Each becomes a
// contract_cargo_scope row.
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).default([]),
// Optional commodity label for the contract PDF (container scope).
cargoCommodityId: z.string().default(""),
// GENERAL only: per-size container quantity cap (total bookable over the
// validity window). Keyed by size; 0/undefined = uncapped. The NumberInput
// can momentarily hold "" / undefined (empty field) — coerce those to 0 so
@@ -279,7 +277,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
cargoType: "container",
enabledContainerSizes: ["20ft"],
cargoCommodityId: "",
containerSizeCaps: {},
cargoTypePath: [],
cargoFreeText: "",
@@ -318,7 +315,6 @@ export const contractStepFields: Record<
1: [
"cargoType",
"enabledContainerSizes",
"cargoCommodityId",
"containerSizeCaps",
"cargoTypePath",
"cargoFreeText",

View File

@@ -100,17 +100,6 @@ export function Step3CargoScope({
);
}, [referenceData, parentId]);
// Commodity options for the optional container commodity label.
const containerCommodityOptions = useMemo(() => {
if (!referenceData?.cargo_type) return [];
return referenceData.cargo_type.flatMap((g) =>
(g.children ?? []).map((c) => ({
value: c.id,
label: `${g.name}${c.name}`,
})),
);
}, [referenceData]);
if (isLoading) {
return (
<div className="space-y-4">
@@ -183,27 +172,6 @@ export function Step3CargoScope({
)}
</div>
{/* Container commodity label (optional). */}
{cargoType === "container" && (
<Stack gap={14}>
{containerCommodityOptions.length > 0 && (
<Controller
name="cargoCommodityId"
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Commodity (optional)"
placeholder="Select a commodity for the contract document…"
data={containerCommodityOptions}
/>
)}
/>
)}
</Stack>
)}
{/* Bulk scope: a single commodity (cargo type path). No tonnage. */}
{cargoType === "bulk" && (
<Stack gap={12} mt={18}>

View File

@@ -4,10 +4,13 @@ import * as z from "zod";
// Shipment booking under a contract (doc §8.1, Path A customer). Captures the
// EXECUTION details the contract scope deliberately omits: a binding scheduled
// date, container quantities + per-unit numbers/seals/VGM, or bulk tonnage.
// Order: Route → Cargo Details → Schedule → Review. Cargo is captured BEFORE
// Schedule so the schedule step can offer only days that are feasible for that
// cargo (enough matching wagons + an open train with capacity).
export const SHIPMENT_STEPS = [
{ id: 0, label: "Route", short: "Route" },
{ id: 1, label: "Schedule", short: "Schedule" },
{ id: 2, label: "Cargo Details", short: "Cargo" },
{ id: 1, label: "Cargo Details", short: "Cargo" },
{ id: 2, label: "Schedule", short: "Schedule" },
{ id: 3, label: "Review", short: "Review" },
] as const;
@@ -82,7 +85,7 @@ export const shipmentStepFields: Record<
Array<Path<ShipmentFormValues>>
> = {
0: ["contractRouteId"],
1: ["scheduledDate"],
2: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
1: ["containers", "cargoWeightTons", "itemCount", "bulkHazardousQuantity"],
2: ["scheduledDate"],
3: ["notes"],
};

View File

@@ -319,6 +319,12 @@ export const api = {
({ originYardId, destinationYardId }) =>
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
),
getAvailableDaysForCargo: endpoint<Freight.AvailableDaysForCargoQuery, string[]>(
"train-scheduling",
"availableDaysForCargo",
(input) => bookingsService.getAvailableDaysForCargo(input),
),
},
contracts: {

View File

@@ -265,4 +265,23 @@ export const bookingsService = {
);
return (data.data as Freight.AvailableDaysResponse).days;
},
// Cargo-aware day pool: only days where a train has remaining capacity AND
// enough matching-type wagons for this cargo. `containers` is serialized as a
// JSON string param (the server parses it).
getAvailableDaysForCargo: async (
query: Freight.AvailableDaysForCargoQuery,
): Promise<string[]> => {
const { containers, ...rest } = query;
const { data } = await client.get(
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS_FOR_CARGO,
{
params: {
...rest,
...(containers ? { containers: JSON.stringify(containers) } : {}),
},
},
);
return (data.data as Freight.AvailableDaysResponse).days;
},
};