mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
refactor contract handling to support single route per contract and improve reference generation logic
This commit is contained in:
@@ -526,23 +526,13 @@ export default function NewContractPage({
|
||||
},
|
||||
];
|
||||
|
||||
// Routes — pure origin→destination lanes, no quantity. Route #1 is primary;
|
||||
// extras only apply to GENERAL contracts.
|
||||
// Route — a single origin→destination lane, general contracts included.
|
||||
const routes: Freight.CreateContractRouteInputDto[] = [
|
||||
{
|
||||
originYardId: data.originYard,
|
||||
destinationYardId: data.destinationYard,
|
||||
sortOrder: 0,
|
||||
},
|
||||
...(isGeneral
|
||||
? (data.extraRoutes ?? [])
|
||||
.filter((r) => r.originYard && r.destinationYard)
|
||||
.map((r, i) => ({
|
||||
originYardId: r.originYard,
|
||||
destinationYardId: r.destinationYard,
|
||||
sortOrder: i + 1,
|
||||
}))
|
||||
: []),
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -52,13 +52,9 @@ export function contractToFormValues(
|
||||
const routes = [...(contract.routes ?? [])].sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
// Contracts carry a single route now; older multi-route GENERAL contracts
|
||||
// load only their primary route.
|
||||
const primaryRoute = routes[0];
|
||||
const extraRoutes = isGeneral
|
||||
? routes.slice(1).map((r) => ({
|
||||
originYard: r.originYardId,
|
||||
destinationYard: r.destinationYardId,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const scope = contract.cargoScope ?? [];
|
||||
|
||||
@@ -131,7 +127,6 @@ export function contractToFormValues(
|
||||
|
||||
originYard: primaryRoute?.originYardId ?? "",
|
||||
destinationYard: primaryRoute?.destinationYardId ?? "",
|
||||
extraRoutes,
|
||||
|
||||
documents: {},
|
||||
};
|
||||
|
||||
@@ -73,7 +73,7 @@ export const CONTRACT_KIND_OPTIONS: Array<{
|
||||
{
|
||||
value: "general_contract",
|
||||
label: "General Contract",
|
||||
description: "Ship multiple times over the validity window across routes.",
|
||||
description: "Ship multiple times over the validity window on one route.",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -107,9 +107,9 @@ export const CONTAINER_SIZES = ["20ft", "40ft"] as const;
|
||||
export type ContainerSize = (typeof CONTAINER_SIZES)[number];
|
||||
|
||||
// A GENERAL-contract quantity cap. The Mantine NumberInput backing these fields
|
||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field); those
|
||||
// all mean "uncapped", so coerce them to 0 before the >= 0 check rather than
|
||||
// letting them fail validation and silently block the Cargo & Route step.
|
||||
// can briefly emit "" / undefined / NaN (cleared or never-touched field);
|
||||
// coerce those to 0 so the superRefine below can flag them with a clear
|
||||
// "greater than 0" message instead of a type error.
|
||||
const nonNegativeQuantityCap = z.preprocess(
|
||||
(v) =>
|
||||
v === "" || v === null || v === undefined || Number.isNaN(v) ? 0 : v,
|
||||
@@ -167,34 +167,23 @@ export const contractFormSchema = z
|
||||
// contract_cargo_scope row.
|
||||
enabledContainerSizes: z.array(z.enum(CONTAINER_SIZES)).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
|
||||
// an untouched cap never blocks the step.
|
||||
// validity window). Keyed by size; must be > 0 for every enabled size
|
||||
// (enforced in the superRefine below).
|
||||
containerSizeCaps: z
|
||||
.record(z.string(), nonNegativeQuantityCap)
|
||||
.default({}),
|
||||
// Bulk scope: the cargo type path (group → commodity).
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
cargoFreeText: z.string().default(""),
|
||||
// GENERAL only: total bulk tons/items bookable. 0 = uncapped. Same empty-
|
||||
// field coercion as the container caps above.
|
||||
// GENERAL only: total bulk tons/items bookable; must be > 0 (superRefine).
|
||||
bulkQuantityCap: nonNegativeQuantityCap.default(0),
|
||||
// Contract-level billing flags.
|
||||
isHazardous: z.boolean().default(false),
|
||||
isRefrigerated: z.boolean().default(false),
|
||||
|
||||
// ── Route ──
|
||||
// ── Route ── (one route per contract — general contracts included)
|
||||
originYard: z.string().min(1, "Select an origin yard."),
|
||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||
// Additional routes for a GENERAL contract (route #1 is the primary above).
|
||||
extraRoutes: z
|
||||
.array(
|
||||
z.object({
|
||||
originYard: z.string().default(""),
|
||||
destinationYard: z.string().default(""),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string().default(""),
|
||||
@@ -260,6 +249,29 @@ export const contractFormSchema = z
|
||||
});
|
||||
}
|
||||
}
|
||||
// GENERAL contracts must carry a real (> 0) quantity cap — an untouched
|
||||
// NumberInput coerces to 0 (see nonNegativeQuantityCap), which blocks the
|
||||
// Cargo & Route step until the customer enters a quantity.
|
||||
if (data.contractKind === "general_contract") {
|
||||
if (data.cargoType === "container") {
|
||||
for (const size of data.enabledContainerSizes) {
|
||||
if (!(data.containerSizeCaps[size] > 0)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["containerSizeCaps", size],
|
||||
message: `Enter a ${size} quantity greater than 0.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (data.cargoType === "bulk" && !(data.bulkQuantityCap > 0)) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["bulkQuantityCap"],
|
||||
message: "Enter a total quantity greater than 0.",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type ContractFormValues = z.infer<typeof contractFormSchema>;
|
||||
@@ -289,7 +301,6 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
|
||||
originYard: "",
|
||||
destinationYard: "",
|
||||
extraRoutes: [],
|
||||
|
||||
documents: {},
|
||||
notes: "",
|
||||
@@ -325,7 +336,6 @@ export const contractStepFields: Record<
|
||||
"isRefrigerated",
|
||||
"originYard",
|
||||
"destinationYard",
|
||||
"extraRoutes",
|
||||
],
|
||||
// Step 2 — Review & Submit. (The separate Documents step was removed — the
|
||||
// company profile documents are attached to the contract automatically.)
|
||||
|
||||
@@ -132,19 +132,12 @@ export function Step1ContractType({
|
||||
);
|
||||
form.setValue("customsClearingAgent", contract.customsClearingAgent ?? "");
|
||||
|
||||
// ── Route (primary + extras) ──
|
||||
// ── Route (single route per contract) ──
|
||||
const routes = contract.routes ?? [];
|
||||
if (routes[0]) {
|
||||
form.setValue("originYard", routes[0].originYardId);
|
||||
form.setValue("destinationYard", routes[0].destinationYardId);
|
||||
}
|
||||
form.setValue(
|
||||
"extraRoutes",
|
||||
routes.slice(1).map((r) => ({
|
||||
originYard: r.originYardId,
|
||||
destinationYard: r.destinationYardId,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Cargo scope ──
|
||||
form.setValue(
|
||||
|
||||
@@ -227,14 +227,14 @@ export function Step3CargoScope({
|
||||
{/* GENERAL contract quantity cap (draw-down ceiling). */}
|
||||
{isGeneral && (
|
||||
<Box>
|
||||
<StepLabel>Booking quantity cap (optional)</StepLabel>
|
||||
<StepLabel>Booking quantity cap *</StepLabel>
|
||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||
Total quantity bookable across all shipments under this contract.
|
||||
Customers / GL can book repeatedly until it is reached. Leave 0 for
|
||||
unlimited.
|
||||
Customers / GL can book repeatedly until it is reached. Must be
|
||||
greater than 0.
|
||||
</Text>
|
||||
{cargoType === "container" ? (
|
||||
<Group gap={12} grow>
|
||||
<Group gap={12} grow align="flex-start">
|
||||
{enabledSizes.length === 0 ? (
|
||||
<Text fz={13} c="dimmed">
|
||||
Select container sizes above to set their caps.
|
||||
@@ -245,13 +245,14 @@ export function Step3CargoScope({
|
||||
key={size}
|
||||
name={`containerSizeCaps.${size}`}
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
label={`${size} cap (containers)`}
|
||||
placeholder="0 = unlimited"
|
||||
label={`${size} cap (containers) *`}
|
||||
placeholder="e.g. 100"
|
||||
min={0}
|
||||
value={Number(field.value ?? 0)}
|
||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
@@ -264,13 +265,14 @@ export function Step3CargoScope({
|
||||
<Controller
|
||||
name="bulkQuantityCap"
|
||||
control={form.control}
|
||||
render={({ field }) => (
|
||||
render={({ field, fieldState }) => (
|
||||
<NumberInput
|
||||
label="Total cap (tons / items)"
|
||||
placeholder="0 = unlimited"
|
||||
label="Total cap (tons / items) *"
|
||||
placeholder="e.g. 500"
|
||||
min={0}
|
||||
value={Number(field.value ?? 0)}
|
||||
onChange={(v) => field.onChange(Number(v) || 0)}
|
||||
error={fieldState.error?.message}
|
||||
radius={10}
|
||||
styles={fieldStyles}
|
||||
/>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
import { Box, Button, Group, Skeleton, Stack, Text } from "@mantine/core";
|
||||
import { MapPin, Plus, Trash2 } from "lucide-react";
|
||||
import { Skeleton, Stack } from "@mantine/core";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
type UseFormReturn,
|
||||
} from "react-hook-form";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { ContractFormInputValues, type ContractFormValues } from "./schema";
|
||||
import { getRouteDirection } from "./helpers";
|
||||
import { SelectField, StepLabel } from "./shared";
|
||||
@@ -29,7 +25,6 @@ export function Step4Route({
|
||||
const originYard = form.watch("originYard");
|
||||
const destinationYard = form.watch("destinationYard");
|
||||
const operationType = form.watch("operationType");
|
||||
const isGeneralContract = form.watch("contractKind") === "general_contract";
|
||||
|
||||
const { originCountry, destinationCountry } = useMemo(() => {
|
||||
switch (operationType) {
|
||||
@@ -46,14 +41,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [operationType]);
|
||||
|
||||
const {
|
||||
fields: extraRoutes,
|
||||
append: appendRoute,
|
||||
remove: removeRoute,
|
||||
} = useFieldArray({ control: form.control, name: "extraRoutes" });
|
||||
|
||||
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
|
||||
|
||||
const yardOptions = useMemo(() => {
|
||||
if (!referenceData?.yard) return [];
|
||||
return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
|
||||
@@ -96,22 +83,6 @@ export function Step4Route({
|
||||
}
|
||||
}, [destinationCountry, dest, form]);
|
||||
|
||||
useEffect(() => {
|
||||
watchedExtraRoutes.forEach((route, i) => {
|
||||
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
|
||||
if (originCountry && ro && ro.country !== originCountry) {
|
||||
form.setValue(`extraRoutes.${i}.originYard`, "");
|
||||
}
|
||||
const rd = referenceData?.yard.find(
|
||||
(y) => y.id === route?.destinationYard,
|
||||
);
|
||||
if (destinationCountry && rd && rd.country !== destinationCountry) {
|
||||
form.setValue(`extraRoutes.${i}.destinationYard`, "");
|
||||
}
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [originCountry, destinationCountry, referenceData, form]);
|
||||
|
||||
const directionStyle: Record<string, string> = {
|
||||
EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
|
||||
IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
@@ -173,94 +144,6 @@ export function Step4Route({
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isGeneralContract && !isLoading && (
|
||||
<Box mt={18}>
|
||||
<Group justify="space-between" align="center" mb={8}>
|
||||
<StepLabel>Additional contract routes</StepLabel>
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
size="xs"
|
||||
radius="md"
|
||||
leftSection={<Plus size={14} />}
|
||||
disabled={stationSelectDisabled}
|
||||
onClick={() =>
|
||||
appendRoute({ originYard: "", destinationYard: "" })
|
||||
}
|
||||
>
|
||||
Add route
|
||||
</Button>
|
||||
</Group>
|
||||
<Text fz={12} c="#6B7C8E" mb={12}>
|
||||
A general contract can cover several routes. The route above is your
|
||||
primary route; add more origin–destination routes the contract
|
||||
should cover.
|
||||
</Text>
|
||||
<Stack gap={12}>
|
||||
{extraRoutes.map((rf, i) => {
|
||||
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
|
||||
const rowDestination =
|
||||
watchedExtraRoutes[i]?.destinationYard ?? "";
|
||||
const rowOriginData = yardsForSide(originCountry, rowDestination);
|
||||
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
|
||||
return (
|
||||
<Group
|
||||
key={rf.id}
|
||||
gap={10}
|
||||
align="flex-start"
|
||||
wrap="nowrap"
|
||||
className="rounded-xl"
|
||||
style={{ border: "1px solid #E6ECF2", padding: 12 }}
|
||||
>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.originYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Origin"
|
||||
placeholder="Origin..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowOriginData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box style={{ flex: 1 }}>
|
||||
<Controller
|
||||
name={`extraRoutes.${i}.destinationYard`}
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<SelectField
|
||||
field={field}
|
||||
error={fieldState.error}
|
||||
label="Destination"
|
||||
placeholder="Destination..."
|
||||
disabled={stationSelectDisabled}
|
||||
data={rowDestData}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="xs"
|
||||
mt={24}
|
||||
px={6}
|
||||
onClick={() => removeRoute(i)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -250,10 +250,6 @@ export function Step8Review({
|
||||
? direction.charAt(0) + direction.slice(1).toLowerCase()
|
||||
: "—";
|
||||
|
||||
const routesCount = 1 + (values.extraRoutes?.filter(
|
||||
(r) => r.originYard && r.destinationYard,
|
||||
).length ?? 0);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<StepHeader
|
||||
@@ -335,17 +331,13 @@ export function Step8Review({
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Route size={18} />}
|
||||
label="Primary route"
|
||||
label="Route"
|
||||
value={`${originYardName} → ${destinationYardName}`}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<MapPin size={18} />}
|
||||
label="Trade direction"
|
||||
value={
|
||||
isGeneralContract
|
||||
? `${directionLabel} · ${routesCount} routes`
|
||||
: directionLabel
|
||||
}
|
||||
value={directionLabel}
|
||||
/>
|
||||
<SummaryItem
|
||||
icon={<Package size={18} />}
|
||||
|
||||
Reference in New Issue
Block a user