mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 03:38:17 +00:00
Merge branch 'freight_feature/contrat' of github.com:Tria-plc/edr-platform into freight_feature/contrat
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user