mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
feat: update booking form to remove per-route quantity input for general contracts
This commit is contained in:
@@ -3,6 +3,16 @@ import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
|
|||||||
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
import { ContainerType } from '../../rule-engine/entities/container-type.entity';
|
||||||
import { BookingOrder } from './booking-order.entity';
|
import { BookingOrder } from './booking-order.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postgres `numeric` columns are serialized to JS strings by the driver. This
|
||||||
|
* transformer hydrates them back into real numbers so consumers (and the
|
||||||
|
* `quantity: number` API type) don't have to coerce on every read.
|
||||||
|
*/
|
||||||
|
const numericColumn = {
|
||||||
|
to: (value: number) => value,
|
||||||
|
from: (value: string | null) => (value == null ? value : Number(value)),
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
* One drawn-down quantity line of an order. For CONTAINER contracts there is one
|
||||||
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
* line per container type (matching the contract's pools); for BULK/BREAK_BULK a
|
||||||
@@ -25,7 +35,7 @@ export class BookingOrderLine extends BaseEntity {
|
|||||||
containerType?: ContainerType | null;
|
containerType?: ContainerType | null;
|
||||||
|
|
||||||
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
/** Containers (count), tons, or items depending on the contract's freight/UoM. */
|
||||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3 })
|
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, transformer: numericColumn })
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,9 +43,23 @@ export class BookingOrderLine extends BaseEntity {
|
|||||||
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
* customer when they toggle the flag. Drives the HAZARD_SURCHARGE /
|
||||||
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
* REEFER_SURCHARGE rates on the spawned child booking. Both ≤ quantity.
|
||||||
*/
|
*/
|
||||||
@Column({ name: 'hazardous_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
@Column({
|
||||||
|
name: 'hazardous_quantity',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 12,
|
||||||
|
scale: 3,
|
||||||
|
default: 0,
|
||||||
|
transformer: numericColumn,
|
||||||
|
})
|
||||||
hazardousQuantity!: number;
|
hazardousQuantity!: number;
|
||||||
|
|
||||||
@Column({ name: 'reefer_quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
@Column({
|
||||||
|
name: 'reefer_quantity',
|
||||||
|
type: 'numeric',
|
||||||
|
precision: 12,
|
||||||
|
scale: 3,
|
||||||
|
default: 0,
|
||||||
|
transformer: numericColumn,
|
||||||
|
})
|
||||||
reeferQuantity!: number;
|
reeferQuantity!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -394,13 +394,10 @@ export default function NewBookingPage() {
|
|||||||
const isPerItem =
|
const isPerItem =
|
||||||
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
|
bulkChild?.unit_of_measure === Freight.CargoUnitOfMeasure.PerItem;
|
||||||
const isContract = data.bookingType === "general_contract";
|
const isContract = data.bookingType === "general_contract";
|
||||||
// For bulk general contracts the contracted quantity is entered against the
|
// Both one-time and general contracts take the bulk amount from the cargo
|
||||||
// primary route in the route step; one-time bookings use the cargo-step
|
// step (cargoWeight) — general contracts no longer collect a per-route
|
||||||
// amount. Item counts are rounded since fractional items are meaningless.
|
// quantity. Item counts are rounded since fractional items are meaningless.
|
||||||
const bulkAmountRaw =
|
const bulkAmountRaw = data.cargoWeight;
|
||||||
isContract && data.cargoType === "bulk"
|
|
||||||
? data.primaryRouteQuantity
|
|
||||||
: data.cargoWeight;
|
|
||||||
const totalWeight =
|
const totalWeight =
|
||||||
data.cargoType === "container"
|
data.cargoType === "container"
|
||||||
? 0
|
? 0
|
||||||
@@ -493,7 +490,9 @@ export default function NewBookingPage() {
|
|||||||
: { customsClearingEnabled: false }),
|
: { customsClearingEnabled: false }),
|
||||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||||
// Multi-route general contracts: route #1 is the primary origin/destination
|
// Multi-route general contracts: route #1 is the primary origin/destination
|
||||||
// carrying the full contracted quantity; each extra route reserves its own.
|
// carrying the full contracted quantity (from the cargo step). Extra routes
|
||||||
|
// are just additional origin/destination pairs the contract covers — no
|
||||||
|
// per-route quantity is collected, so they are sent with quantity 0.
|
||||||
...(isContract
|
...(isContract
|
||||||
? {
|
? {
|
||||||
routes: [
|
routes: [
|
||||||
@@ -509,17 +508,11 @@ export default function NewBookingPage() {
|
|||||||
: totalWeight,
|
: totalWeight,
|
||||||
},
|
},
|
||||||
...(data.extraRoutes ?? [])
|
...(data.extraRoutes ?? [])
|
||||||
.filter(
|
.filter((r) => r.originYard && r.destinationYard)
|
||||||
(r) =>
|
|
||||||
r.originYard &&
|
|
||||||
r.destinationYard &&
|
|
||||||
Number(r.quantity) > 0,
|
|
||||||
)
|
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
originYardId: r.originYard,
|
originYardId: r.originYard,
|
||||||
destinationYardId: r.destinationYard,
|
destinationYardId: r.destinationYard,
|
||||||
quantity: Number(r.quantity),
|
quantity: 0,
|
||||||
...(r.km && Number(r.km) > 0 ? { km: Number(r.km) } : {}),
|
|
||||||
})),
|
})),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -140,22 +140,20 @@ export const bookingFormSchema = z
|
|||||||
customsClearingAgent: z.string().default(""),
|
customsClearingAgent: z.string().default(""),
|
||||||
originYard: z.string().min(1, "Select an origin yard."),
|
originYard: z.string().min(1, "Select an origin yard."),
|
||||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||||
// Quantity reserved on the PRIMARY route of a GENERAL contract, in the unit
|
// Retained for payload/back-compat only — no longer collected in the UI.
|
||||||
// of the selected commodity (items vs tons). Customers enter it explicitly in
|
// The contracted quantity now comes from the cargo step (cargoWeight), the
|
||||||
// the route step so the primary route reads consistently with the extra
|
// same as a one-time booking, so per-route quantity is no longer entered.
|
||||||
// routes below. Ignored for one-time bookings; for containers the value is
|
|
||||||
// derived from the container count instead (see buildApiPayload).
|
|
||||||
primaryRouteQuantity: z.string().default(""),
|
primaryRouteQuantity: z.string().default(""),
|
||||||
// Additional routes for a GENERAL contract (the primary origin/destination
|
// Additional routes for a GENERAL contract (the primary origin/destination
|
||||||
// above is route #1). Each adds another (origin, destination, quantity) pool.
|
// above is route #1). Each route is just an (origin, destination) pair —
|
||||||
// Ignored for one-time bookings.
|
// identical to the one-time route — so a contract can cover several routes.
|
||||||
|
// Ignored for one-time bookings. quantity/km kept for payload back-compat.
|
||||||
extraRoutes: z
|
extraRoutes: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
originYard: z.string(),
|
originYard: z.string(),
|
||||||
destinationYard: z.string(),
|
destinationYard: z.string(),
|
||||||
quantity: z.string(),
|
quantity: z.string().default(""),
|
||||||
// Road distance for this route; used to bill road (truck) orders.
|
|
||||||
km: z.string().default(""),
|
km: z.string().default(""),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -221,11 +219,10 @@ export const bookingFormSchema = z
|
|||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
// General contracts capture bulk quantity per route (primaryRouteQuantity),
|
// Both one-time and general contracts capture the bulk amount in the cargo
|
||||||
// not via the cargo-step cargoWeight — so only validate it for one-time
|
// step (cargoWeight). General contracts no longer collect a per-route
|
||||||
// bulk bookings.
|
// quantity, so the cargo amount is the single source for the contract total.
|
||||||
if (data.cargoType !== "bulk") return true;
|
if (data.cargoType !== "bulk") return true;
|
||||||
if (data.bookingType === "general_contract") return true;
|
|
||||||
const quantity = Number(data.cargoWeight);
|
const quantity = Number(data.cargoWeight);
|
||||||
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
|
return !!data.cargoWeight && !Number.isNaN(quantity) && quantity > 0;
|
||||||
},
|
},
|
||||||
@@ -257,19 +254,6 @@ export const bookingFormSchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// General contracts reserve quantity per route. The primary route's quantity
|
|
||||||
// is entered in the route step; containers derive it from the container
|
|
||||||
// count, so only bulk cargo requires it here.
|
|
||||||
if (data.bookingType === "general_contract" && data.cargoType === "bulk") {
|
|
||||||
const qty = Number(data.primaryRouteQuantity);
|
|
||||||
if (!data.primaryRouteQuantity || Number.isNaN(qty) || qty <= 0) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["primaryRouteQuantity"],
|
|
||||||
message: "Enter a quantity greater than 0.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (data.cargoType === "container") {
|
if (data.cargoType === "container") {
|
||||||
data.containers.forEach((c, i) => {
|
data.containers.forEach((c, i) => {
|
||||||
if (!c.qty || +c.qty < 1) {
|
if (!c.qty || +c.qty < 1) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Divider,
|
Divider,
|
||||||
Group,
|
Group,
|
||||||
NumberInput,
|
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
@@ -137,25 +136,11 @@ export function Step4Route({
|
|||||||
|
|
||||||
const stationSelectDisabled = yardOptions.length === 0;
|
const stationSelectDisabled = yardOptions.length === 0;
|
||||||
|
|
||||||
// General contracts reserve quantity per route. The unit (items vs tons) comes
|
// A general contract can cover several routes, but each route is just an
|
||||||
// from the commodity picked in the cargo step, mirroring step5-cargo-details:
|
// (origin, destination) pair — the same shape as the one-time route. The
|
||||||
// PER_ITEM → a whole item count; otherwise an estimated tonnage. Container
|
// contracted quantity comes from the cargo step, so no per-route quantity or
|
||||||
// contracts reserve quantity by container count instead, so no quantity input
|
// distance is collected here.
|
||||||
// is shown for them here.
|
|
||||||
const cargoType = form.watch("cargoType");
|
const cargoType = form.watch("cargoType");
|
||||||
const cargoTypePath = form.watch("cargoTypePath") ?? [];
|
|
||||||
const isContainer = cargoType === "container";
|
|
||||||
const selectedCommodity = useMemo(() => {
|
|
||||||
const parentId = cargoTypePath[0];
|
|
||||||
const childId = cargoTypePath[1];
|
|
||||||
if (!referenceData?.cargo_type || !parentId || !childId) return null;
|
|
||||||
const group = referenceData.cargo_type.find((g) => g.id === parentId);
|
|
||||||
return group?.children?.find((c) => c.id === childId) ?? null;
|
|
||||||
}, [referenceData, cargoTypePath]);
|
|
||||||
const isPerItem = selectedCommodity?.unit_of_measure === "PER_ITEM";
|
|
||||||
const quantityLabel = isPerItem ? "Quantity (Items)" : "Quantity (Tons)";
|
|
||||||
const quantityStep = isPerItem ? 1 : 0.01;
|
|
||||||
const showRouteQuantity = isGeneralContract && !isContainer;
|
|
||||||
|
|
||||||
// The reefer toggle only exists for bulk; if the customer switches to
|
// The reefer toggle only exists for bulk; if the customer switches to
|
||||||
// containers, drop any reefer flag they set so it can't ride along unseen.
|
// containers, drop any reefer flag they set so it can't ride along unseen.
|
||||||
@@ -246,27 +231,6 @@ export function Step4Route({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{showRouteQuantity && (
|
|
||||||
<Box style={{ maxWidth: 220 }}>
|
|
||||||
<Controller
|
|
||||||
name="primaryRouteQuantity"
|
|
||||||
control={form.control}
|
|
||||||
render={({ field, fieldState }) => (
|
|
||||||
<NumberInput
|
|
||||||
label={`${quantityLabel} *`}
|
|
||||||
placeholder={isPerItem ? "e.g. 500" : "e.g. 1200"}
|
|
||||||
description="Quantity reserved on the primary route."
|
|
||||||
min={0}
|
|
||||||
step={quantityStep}
|
|
||||||
error={fieldState.error?.message}
|
|
||||||
value={field.value === "" ? "" : Number(field.value)}
|
|
||||||
onChange={(v) => field.onChange(String(v ?? ""))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -294,9 +258,9 @@ export function Step4Route({
|
|||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fz={12} c="#6B7C8E" mb={12}>
|
<Text fz={12} c="#6B7C8E" mb={12}>
|
||||||
A general contract can reserve quantity across several routes. The
|
A general contract can cover several routes. The route above is your
|
||||||
route above is your primary route; add more routes and set the
|
primary route; add more origin–destination routes the contract should
|
||||||
quantity reserved for each.
|
cover.
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={12}>
|
<Stack gap={12}>
|
||||||
{extraRoutes.map((rf, i) => (
|
{extraRoutes.map((rf, i) => (
|
||||||
@@ -338,40 +302,6 @@ export function Step4Route({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box style={{ width: 140 }}>
|
|
||||||
<Controller
|
|
||||||
name={`extraRoutes.${i}.quantity`}
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<NumberInput
|
|
||||||
label={quantityLabel}
|
|
||||||
placeholder="0"
|
|
||||||
min={0}
|
|
||||||
step={quantityStep}
|
|
||||||
value={field.value === "" ? "" : Number(field.value)}
|
|
||||||
onChange={(v) => field.onChange(String(v ?? ""))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Box style={{ width: 110 }}>
|
|
||||||
<Controller
|
|
||||||
name={`extraRoutes.${i}.km`}
|
|
||||||
control={form.control}
|
|
||||||
render={({ field }) => (
|
|
||||||
<NumberInput
|
|
||||||
label="Distance (km)"
|
|
||||||
placeholder="0"
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
value={field.value === "" ? "" : Number(field.value)}
|
|
||||||
onChange={(v) => field.onChange(String(v ?? ""))}
|
|
||||||
radius="md"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -160,14 +160,10 @@ export function Step8Review({
|
|||||||
.join(", ")
|
.join(", ")
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
// For bulk general contracts the quantity is reserved per route (the primary
|
|
||||||
// route's amount lives in primaryRouteQuantity); one-time bookings use the
|
|
||||||
// cargo-step cargoWeight.
|
|
||||||
const isGeneralContract = values.bookingType === "general_contract";
|
const isGeneralContract = values.bookingType === "general_contract";
|
||||||
const bulkAmount =
|
// Both one-time and general contracts take the bulk amount from the cargo step
|
||||||
isGeneralContract && values.cargoType === "bulk"
|
// (cargoWeight); general contracts no longer collect a per-route quantity.
|
||||||
? Number(values.primaryRouteQuantity || 0)
|
const bulkAmount = Number(values.cargoWeight || 0);
|
||||||
: Number(values.cargoWeight || 0);
|
|
||||||
const totalVgm =
|
const totalVgm =
|
||||||
values.cargoType === "container"
|
values.cargoType === "container"
|
||||||
? values.containers.reduce(
|
? values.containers.reduce(
|
||||||
|
|||||||
@@ -356,14 +356,15 @@ export default function ContractDetailPage() {
|
|||||||
Ship {new Date(order.scheduledDate).toLocaleDateString()}
|
Ship {new Date(order.scheduledDate).toLocaleDateString()}
|
||||||
{" · "}
|
{" · "}
|
||||||
{order.lines
|
{order.lines
|
||||||
.map(
|
.map((l) => {
|
||||||
(l) =>
|
const qty = Number(l.quantity);
|
||||||
`${Number.isInteger(l.quantity) ? l.quantity : l.quantity.toFixed(2)}${
|
const label = Number.isInteger(qty)
|
||||||
l.containerTypeName
|
? `${qty}`
|
||||||
? ` ${l.containerTypeName}`
|
: qty.toFixed(2);
|
||||||
: ""
|
return `${label}${
|
||||||
}`,
|
l.containerTypeName ? ` ${l.containerTypeName}` : ""
|
||||||
)
|
}`;
|
||||||
|
})
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user