mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
feat: implement shipping line bookings management
- Add ShippingLineBookingsPage for listing and managing shipping line bookings. - Create ShippingLineDocumentsModal for document uploads related to bookings. - Introduce ShippingLineInitiateModal for initiating new shipping line bookings. - Implement booking document state management with booking-doc-state utility. - Add shipping line bookings service for API interactions. - Update index to export new components and services. - Enhance types for freight to include shipping line credits.
This commit is contained in:
@@ -257,6 +257,34 @@ const RuleEngineFormDialog = ({
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Turning the shipping-line toggle on or off swaps the entire form, so
|
||||
// nothing answered under the other shape may survive into the payload.
|
||||
if (name === "isShippingLineRate") {
|
||||
next.shippingLineCompanyId = "";
|
||||
next.shippingLineRateKind = "";
|
||||
next.shippingLineCargoKind = "";
|
||||
next.appliesTo = "";
|
||||
next.trigger = "";
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
next.rateUnit = "";
|
||||
}
|
||||
// Base-vs-surcharge and container-vs-bulk each decide the scope field and
|
||||
// the legal units for a shipping-line rate, exactly as appliesTo and
|
||||
// cargoKind do on the customer form.
|
||||
if (name === "shippingLineRateKind" || name === "shippingLineCargoKind") {
|
||||
next.containerTypeId = "";
|
||||
next.cargoTypeId = "";
|
||||
next.rateUnit = "";
|
||||
if (name === "shippingLineRateKind") {
|
||||
next.shippingLineCargoKind = "";
|
||||
next.trigger = "";
|
||||
next.originYardId = "";
|
||||
next.destinationYardId = "";
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -347,12 +375,23 @@ const RuleEngineFormDialog = ({
|
||||
borderRadius: "var(--mantine-radius-md)",
|
||||
}}
|
||||
>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={600}>
|
||||
{field.label}
|
||||
</Text>
|
||||
{field.description ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{field.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={Boolean(values[field.name])}
|
||||
onChange={(e) => setField(field.name, e.currentTarget.checked)}
|
||||
// A toggle that re-targets what an existing record means (e.g. who
|
||||
// a rate is priced for) is create-only — flipping it on a saved row
|
||||
// would silently change every booking that prices off it.
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord)}
|
||||
size="md"
|
||||
color="edr-green"
|
||||
/>
|
||||
@@ -493,6 +532,12 @@ const RuleEngineFormDialog = ({
|
||||
// Dynamic options (e.g. rate unit) resolve from the live form values so
|
||||
// the choices track the other fields the admin has picked.
|
||||
const options = field.optionsFromValues ? field.optionsFromValues(values) : (field.options ?? []);
|
||||
// A derived select shows (and submits) its computed value and is locked,
|
||||
// matching the text-input branch — used by fields the shape decides on the
|
||||
// admin's behalf, e.g. a shipping-line rate's import-only direction.
|
||||
const computedSelect = field.computeValue
|
||||
? String(field.computeValue(values) ?? "")
|
||||
: undefined;
|
||||
return (
|
||||
<Select
|
||||
key={field.name}
|
||||
@@ -501,9 +546,18 @@ const RuleEngineFormDialog = ({
|
||||
placeholder={
|
||||
selectOptionsLoading ? "Loading options..." : (field.placeholder ?? "Select an option")
|
||||
}
|
||||
value={resolveSelectValue(field, values)}
|
||||
value={
|
||||
computedSelect !== undefined
|
||||
? computedSelect
|
||||
: resolveSelectValue(field, values)
|
||||
}
|
||||
onChange={(v) => setField(field.name, v === RULE_ENGINE_SELECT_NONE ? "" : v)}
|
||||
disabled={selectOptionsLoading}
|
||||
disabled={
|
||||
selectOptionsLoading ||
|
||||
field.disabled ||
|
||||
(field.disabledOnEdit && !!initialRecord) ||
|
||||
computedSelect !== undefined
|
||||
}
|
||||
// Mantine's Select is not a native input, so `required` only marks it
|
||||
// visually — handleSubmit is what actually blocks an empty one.
|
||||
required={field.required}
|
||||
|
||||
@@ -3,6 +3,8 @@ import toast from "react-hot-toast";
|
||||
|
||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||
import { api } from "@/services/api";
|
||||
import { shippingLineCompaniesService } from "@/services/shippingLineCompanies.service";
|
||||
import type { PaginatedShippingLineCompanies } from "@/types/shippingLineCompany";
|
||||
import {
|
||||
ruleEngineService,
|
||||
type RuleEngineListParams,
|
||||
@@ -165,6 +167,28 @@ export const useContainerTypeOptions = (
|
||||
select: (rows) => buildContainerTypeSelectOptions(rows, includeNone),
|
||||
});
|
||||
|
||||
/**
|
||||
* Shipping lines a rate can be scoped to. Only ACTIVE lines are offered — the
|
||||
* API refuses a rate filed against a suspended one, so listing them would only
|
||||
* produce an error on submit. Sorted by name so the picker is scannable.
|
||||
*/
|
||||
export const useShippingLineCompanyOptions = (enabled = true) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.RULE_ENGINE.selectOptions("shipping-line-companies", {}),
|
||||
// One page well past the number of carriers on the corridor; the picker
|
||||
// needs the whole list, not a page of it.
|
||||
queryFn: () => shippingLineCompaniesService.list(1, 200),
|
||||
enabled,
|
||||
select: (page: PaginatedShippingLineCompanies) =>
|
||||
page.items
|
||||
.filter((line) => line.status === "active")
|
||||
.map((line) => ({
|
||||
label: line.scacCode ? `${line.name} (${line.scacCode})` : line.name,
|
||||
value: line.id,
|
||||
}))
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
});
|
||||
|
||||
/**
|
||||
* Approval-step role options, sourced from the live IAM position types. The
|
||||
* three pre-IAM role strings are appended (marked "(legacy)") so an approval
|
||||
|
||||
@@ -136,9 +136,20 @@ export default function DocumentClearanceDetailPage() {
|
||||
clearance?.milestones?.some(
|
||||
(m) => m.milestoneCode === "DOCUMENTS_APPROVED" && m.status === "COMPLETED",
|
||||
) ?? false;
|
||||
const queriesLocked = Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.preClearanceFinalized,
|
||||
);
|
||||
// Querying a document is only possible while the booking is actually in
|
||||
// review — the server enforces exactly that (reviewDocument asserts
|
||||
// DOCUMENTS_UNDER_REVIEW), so once clearance is finalized the button could
|
||||
// only ever produce a 400.
|
||||
//
|
||||
// `preClearanceFinalized` alone was not enough: it is a phased-customs field,
|
||||
// so a non-customs booking (self-clearance, and every shipping-line booking)
|
||||
// never sets it and kept offering Query after Operations had finalized.
|
||||
const queriesLocked =
|
||||
Boolean(
|
||||
(clearance as Freight.ContractClearanceView | undefined)
|
||||
?.preClearanceFinalized,
|
||||
) ||
|
||||
(booking?.status != null && booking.status !== "DOCUMENTS_UNDER_REVIEW");
|
||||
const workflowFiles =
|
||||
(clearance as Freight.ContractClearanceView | undefined)?.workflowFiles ?? [];
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
useCargoTypeParentOptions,
|
||||
useContainerTypeOptions,
|
||||
useLiveRateOptions,
|
||||
useShippingLineCompanyOptions,
|
||||
useWagonTypeOptions,
|
||||
useYardOptions,
|
||||
type YardOption,
|
||||
@@ -93,6 +94,20 @@ const yardOptionsForLegEnd = (
|
||||
values: Record<string, unknown>,
|
||||
end: "origin" | "destination",
|
||||
): { label: string; value: string }[] => {
|
||||
// A shipping-line rate names its shape in its own fields and is always
|
||||
// import; map it onto the appliesTo/direction pair the rest of this function
|
||||
// reads so the country narrowing is shared rather than duplicated.
|
||||
if (values.isShippingLineRate === true) {
|
||||
if (!values.shippingLineCompanyId) return [];
|
||||
const isBase = values.shippingLineRateKind === "BASE";
|
||||
values = {
|
||||
...values,
|
||||
appliesTo: isBase
|
||||
? String(values.shippingLineCargoKind ?? "")
|
||||
: "OTHER",
|
||||
tradeDirection: "IMPORT",
|
||||
};
|
||||
}
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
let country: string | undefined;
|
||||
if (appliesTo === "INTERCITY") {
|
||||
@@ -280,6 +295,13 @@ const RuleEngineResourcePage = () => {
|
||||
useContainerTypeOptions(false, usesContainerTypeField);
|
||||
const { data: liveRateOptions, isLoading: liveRateOptionsLoading } =
|
||||
useLiveRateOptions(usesLiveRateField);
|
||||
const usesShippingLineField = Boolean(
|
||||
config?.formFields.some((f) => f.name === "shippingLineCompanyId"),
|
||||
);
|
||||
const {
|
||||
data: shippingLineOptions,
|
||||
isLoading: shippingLineOptionsLoading,
|
||||
} = useShippingLineCompanyOptions(usesShippingLineField);
|
||||
const { data: wagonTypeOptions, isLoading: wagonTypeOptionsLoading } =
|
||||
useWagonTypeOptions(usesWagonTypeField);
|
||||
const usesYardField = Boolean(
|
||||
@@ -390,6 +412,13 @@ const RuleEngineResourcePage = () => {
|
||||
),
|
||||
};
|
||||
}
|
||||
if (field.name === "shippingLineCompanyId") {
|
||||
return {
|
||||
...field,
|
||||
type: "select" as const,
|
||||
options: shippingLineOptions ?? [],
|
||||
};
|
||||
}
|
||||
if (field.name === "rateId") {
|
||||
return {
|
||||
...field,
|
||||
@@ -612,7 +641,39 @@ const RuleEngineResourcePage = () => {
|
||||
|
||||
const handleFormSubmit = (values: Record<string, unknown>) => {
|
||||
let payload = values;
|
||||
if (config.slug === "rates") {
|
||||
if (config.slug === "rates" && values.isShippingLineRate === true) {
|
||||
// A shipping-line rate asks its shape as "base freight vs surcharge" +
|
||||
// "container vs bulk"; the API takes the same appliesTo/trigger pair as a
|
||||
// customer rate, so translate here and drop the form-only fields. Always
|
||||
// import (the only direction a line ships) and always USD.
|
||||
const {
|
||||
isShippingLineRate: _toggle,
|
||||
shippingLineRateKind,
|
||||
shippingLineCargoKind,
|
||||
...rest
|
||||
} = values;
|
||||
void _toggle;
|
||||
const isBase = shippingLineRateKind === "BASE";
|
||||
payload = {
|
||||
...rest,
|
||||
appliesTo: isBase ? String(shippingLineCargoKind ?? "CONTAINER") : "OTHER",
|
||||
trigger: isBase ? "ALWAYS" : values.trigger,
|
||||
tradeDirection: "IMPORT",
|
||||
currency: "USD",
|
||||
};
|
||||
if (editing?.id && editing.status === "LIVE") {
|
||||
rateChangeWorkflow.submit.mutate(
|
||||
{ rateId: String(editing.id), update: payload },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setFormOpen(false);
|
||||
setEditing(null);
|
||||
},
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (config.slug === "rates") {
|
||||
// Base-freight categories have no surcharge trigger field — the engine
|
||||
// treats them as ALWAYS. Surcharges (Applies to = Other) keep their
|
||||
// chosen trigger.
|
||||
@@ -934,6 +995,7 @@ const RuleEngineResourcePage = () => {
|
||||
(usesLiveRateField && liveRateOptionsLoading) ||
|
||||
(usesWagonTypeField && wagonTypeOptionsLoading) ||
|
||||
(usesYardField && yardOptionsLoading) ||
|
||||
(usesShippingLineField && shippingLineOptionsLoading) ||
|
||||
(usesApprovalRoleField && approvalRoleOptionsLoading)
|
||||
}
|
||||
positionOptions={!editing ? createPositionOptions : undefined}
|
||||
|
||||
@@ -97,7 +97,16 @@ export interface RuleEngineOrderConfig {
|
||||
export interface RuleEngineListTab {
|
||||
key: string;
|
||||
label: string;
|
||||
filters: { appliesTo?: string; trigger?: string };
|
||||
filters: {
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* "true" = only shipping-line rates, "false" = only standard customer
|
||||
* rates. Sent as a string because tab filters go on the query string
|
||||
* verbatim.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuleEngineResourceConfig {
|
||||
@@ -205,6 +214,36 @@ const INTERCITY_KINDS = [
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/**
|
||||
* A shipping-line rate: priced for one carrier's own bookings instead of for
|
||||
* every customer. The toggle drives the whole form — until a line is picked
|
||||
* there is nothing to configure, and the shape questions (base freight vs
|
||||
* surcharge, container vs bulk) are asked only after it is.
|
||||
*/
|
||||
const isShippingLineRate = (values: Record<string, unknown>) =>
|
||||
values.isShippingLineRate === true;
|
||||
|
||||
/** A shipping-line rate whose owning line has been chosen — the rest unlocks. */
|
||||
const hasShippingLine = (values: Record<string, unknown>) =>
|
||||
isShippingLineRate(values) && Boolean(values.shippingLineCompanyId);
|
||||
|
||||
/**
|
||||
* What a shipping-line rate prices. Deliberately narrower than the customer
|
||||
* form's `appliesTo`: a line buys base rail freight (its own containers or
|
||||
* bulk) or a surcharge, and nothing else — intercity and first/last mile are
|
||||
* customer products.
|
||||
*/
|
||||
const SHIPPING_LINE_RATE_KINDS = [
|
||||
{ label: "Base freight", value: "BASE" },
|
||||
{ label: "Surcharge", value: "SURCHARGE" },
|
||||
];
|
||||
|
||||
/** Container vs bulk, asked once a shipping-line base-freight rate is chosen. */
|
||||
const SHIPPING_LINE_CARGO_KINDS = [
|
||||
{ label: "Container", value: "CONTAINER" },
|
||||
{ label: "Bulk", value: "BULK" },
|
||||
];
|
||||
|
||||
/** True when the rate being edited is base rail freight, which is priced per leg. */
|
||||
const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
|
||||
@@ -214,7 +253,15 @@ const isBaseFreightRate = (values: Record<string, unknown>) =>
|
||||
* the empty-container return surcharge (sold per route + container type).
|
||||
*/
|
||||
const isRouteScopedRate = (values: Record<string, unknown>) =>
|
||||
isBaseFreightRate(values) ||
|
||||
// A shipping line's base freight is priced per leg exactly like a customer's;
|
||||
// its surcharges are route-scoped on the same triggers.
|
||||
(isShippingLineRate(values)
|
||||
? hasShippingLine(values) &&
|
||||
(values.shippingLineRateKind === "BASE" ||
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(
|
||||
String(values.trigger ?? ""),
|
||||
))
|
||||
: isBaseFreightRate(values)) ||
|
||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||
|
||||
@@ -303,6 +350,31 @@ export const rateUnitOptions = (
|
||||
values: Record<string, unknown>,
|
||||
cargoUnitOfMeasure = "",
|
||||
) => {
|
||||
// A shipping-line rate answers the same two questions under different names —
|
||||
// map them onto the shape the unit table is keyed by. Base freight for a line
|
||||
// is CONTAINER/BULK freight; a line surcharge is OTHER + its trigger.
|
||||
if (isShippingLineRate(values)) {
|
||||
const { shippingLineRateKind: kind, shippingLineCargoKind: cargoKind } = values;
|
||||
if (kind === "BASE") {
|
||||
if (cargoKind !== "CONTAINER" && cargoKind !== "BULK") return [];
|
||||
return allowedRateUnits(
|
||||
String(cargoKind),
|
||||
"ALWAYS",
|
||||
"",
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
if (kind === "SURCHARGE" && values.trigger) {
|
||||
return allowedRateUnits(
|
||||
"OTHER",
|
||||
String(values.trigger),
|
||||
String(values.cargoKind ?? ""),
|
||||
cargoUnitOfMeasure,
|
||||
).map(unitOption);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const appliesTo = String(values.appliesTo ?? "");
|
||||
const trigger = appliesTo === "OTHER" ? String(values.trigger ?? "") : "ALWAYS";
|
||||
if (!appliesTo) return [];
|
||||
@@ -830,36 +902,51 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
supportsSearch: true,
|
||||
// Category tabs — each filters server-side by appliesTo / trigger.
|
||||
listTabs: [
|
||||
{ key: "all", label: "All", filters: {} },
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY" } },
|
||||
// "All" and every shape tab show customer rates only — a shipping line's
|
||||
// negotiated price is its own list, not an extra row in the standard one.
|
||||
{ key: "all", label: "All", filters: { isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "shipping-line",
|
||||
label: "Shipping line",
|
||||
filters: { isShippingLineRate: "true" },
|
||||
},
|
||||
{ key: "container", label: "Container", filters: { appliesTo: "CONTAINER", isShippingLineRate: "false" } },
|
||||
{ key: "bulk", label: "Bulk", filters: { appliesTo: "BULK", isShippingLineRate: "false" } },
|
||||
{ key: "intercity", label: "Intercity", filters: { appliesTo: "INTERCITY", isShippingLineRate: "false" } },
|
||||
{
|
||||
key: "trucking",
|
||||
label: "First / Last mile",
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
|
||||
filters: { appliesTo: "FIRST_MILE,LAST_MILE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "customs",
|
||||
label: "Customs clearance",
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE" },
|
||||
filters: { trigger: "CUSTOMS_CLEARANCE", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "return",
|
||||
label: "Container return",
|
||||
filters: { trigger: "WITH_RETURN" },
|
||||
filters: { trigger: "WITH_RETURN", isShippingLineRate: "false" },
|
||||
},
|
||||
{
|
||||
key: "surcharges",
|
||||
label: "Surcharges",
|
||||
filters: {
|
||||
appliesTo: "OTHER",
|
||||
isShippingLineRate: "false",
|
||||
trigger:
|
||||
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,PIL_EXTRA_FEE,FUEL",
|
||||
},
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
// Blank on a standard customer rate; the owning carrier on a line rate.
|
||||
{
|
||||
id: "shippingLineCompany",
|
||||
header: "Shipping line",
|
||||
accessorKey: "shippingLineCompany",
|
||||
format: "entityLabel",
|
||||
},
|
||||
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
|
||||
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
|
||||
// Base freight is priced per leg, so the route is what tells two otherwise
|
||||
@@ -879,6 +966,59 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
{ id: "status", header: "Status", accessorKey: "status", format: "rateStatus" },
|
||||
],
|
||||
formFields: [
|
||||
// ── Shipping line rate ────────────────────────────────────────────────
|
||||
// Flipping this on replaces the whole customer form: the only question
|
||||
// is which line, and the shape questions follow once it is answered.
|
||||
{
|
||||
name: "isShippingLineRate",
|
||||
label: "Shipping line rate",
|
||||
type: "boolean",
|
||||
description:
|
||||
"Price this rate for one shipping line's own bookings instead of for every customer. A line rate replaces the standard rate on that lane — it does not add to it.",
|
||||
// The owner is part of a rate's identity, so switching an existing rate
|
||||
// between customer and line pricing would silently re-target every
|
||||
// booking that prices off it. Create a new rate instead.
|
||||
disabledOnEdit: true,
|
||||
getInitialValue: (record) => Boolean(record.shippingLineCompanyId),
|
||||
},
|
||||
{
|
||||
name: "shippingLineCompanyId",
|
||||
label: "Shipping line",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which shipping line this rate is for",
|
||||
description:
|
||||
"Only this line's bookings price off this rate. A lane the line has no rate for is blocked at booking rather than falling back to the customer price.",
|
||||
disabledOnEdit: true,
|
||||
showIf: isShippingLineRate,
|
||||
},
|
||||
// What the line is buying. Asked only after a line is picked, so the form
|
||||
// stays a single question until then.
|
||||
{
|
||||
name: "shippingLineRateKind",
|
||||
label: "Rate type",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_RATE_KINDS,
|
||||
placeholder: "Base freight or a surcharge?",
|
||||
showIf: hasShippingLine,
|
||||
// Not stored: base freight carries trigger ALWAYS, a surcharge anything else.
|
||||
getInitialValue: (record) =>
|
||||
!record.trigger || record.trigger === "ALWAYS" ? "BASE" : "SURCHARGE",
|
||||
},
|
||||
// Container vs bulk — the line form asks this directly instead of folding
|
||||
// it into `appliesTo` the way the customer form does.
|
||||
{
|
||||
name: "shippingLineCargoKind",
|
||||
label: "Cargo kind",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: SHIPPING_LINE_CARGO_KINDS,
|
||||
placeholder: "Is this rate for containers or bulk?",
|
||||
showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "BASE",
|
||||
getInitialValue: (record) =>
|
||||
record.appliesTo === "BULK" ? "BULK" : "CONTAINER",
|
||||
},
|
||||
{
|
||||
name: "appliesTo",
|
||||
label: "Applies to",
|
||||
@@ -887,6 +1027,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_APPLIES_TO,
|
||||
description:
|
||||
"Pick what this rate is for. Bulk/Container/Intercity are base freight; Other is an auto-applied surcharge.",
|
||||
// Derived from the two questions above on a shipping-line rate.
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// ── Surcharge trigger — only when Applies to = Other ──────────────────
|
||||
{
|
||||
@@ -897,6 +1039,20 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showWhen: { field: "appliesTo", equals: ["OTHER"] },
|
||||
showIf: (v) => !isShippingLineRate(v),
|
||||
},
|
||||
// The same trigger list for a shipping-line surcharge — a line incurs the
|
||||
// same charges a customer does (hazard, reefer, demurrage …), just at its
|
||||
// own negotiated price.
|
||||
{
|
||||
name: "trigger",
|
||||
label: "Surcharge trigger",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: RATE_TRIGGERS,
|
||||
placeholder: "What makes this surcharge apply?",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||
},
|
||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
||||
// scoped surcharges (customs clearance; empty-container return, which is
|
||||
@@ -915,11 +1071,31 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
? FUEL_TRADE_DIRECTIONS
|
||||
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
|
||||
showIf: (v) =>
|
||||
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
)),
|
||||
!isShippingLineRate(v) &&
|
||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
||||
String(v.trigger ?? ""),
|
||||
))),
|
||||
},
|
||||
// Shipping lines only ever ship import — the export leg is sold through
|
||||
// the customer's contract — so the direction is stated, not asked. Shown
|
||||
// as a locked field rather than hidden so the lane the yard pickers are
|
||||
// filtered by is visible.
|
||||
{
|
||||
name: "tradeDirection",
|
||||
label: "Trade direction",
|
||||
type: "select",
|
||||
required: true,
|
||||
options: [{ label: "Import", value: "IMPORT" }],
|
||||
description: "Shipping line rates are import-only.",
|
||||
disabled: true,
|
||||
// No defaultValue: field names repeat across form variants and the
|
||||
// seeded initial value is shared, so defaulting here would pre-select
|
||||
// Import on the customer form's own direction field too. computeValue
|
||||
// pins IMPORT on submit and locks the input regardless.
|
||||
computeValue: () => "IMPORT",
|
||||
showIf: hasShippingLine,
|
||||
},
|
||||
// ── Cargo kind — customs clearance is priced separately for containers
|
||||
// (one rate per container type) and bulk ───────────────────────────────
|
||||
@@ -1089,9 +1265,24 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select container type (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "CONTAINER" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
|
||||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN")),
|
||||
},
|
||||
// Container type for a shipping-line base-freight rate. Required here,
|
||||
// unlike the customer form's optional catch-all: a line negotiates a
|
||||
// price per box size, so an unscoped line rate has no meaning.
|
||||
{
|
||||
name: "containerTypeId",
|
||||
label: "Container type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which container type this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "CONTAINER",
|
||||
},
|
||||
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
|
||||
{
|
||||
@@ -1101,8 +1292,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
optional: true,
|
||||
placeholder: "Select bulk commodity (optional)",
|
||||
showIf: (v) =>
|
||||
v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK"),
|
||||
!isShippingLineRate(v) &&
|
||||
(v.appliesTo === "BULK" ||
|
||||
(v.appliesTo === "INTERCITY" && v.intercityKind === "BULK")),
|
||||
},
|
||||
// Bulk commodity for a shipping-line base-freight rate. Its unit of
|
||||
// measure decides the rate unit offered below — a counted commodity
|
||||
// (PER_ITEM) prices per item where a weighed one prices per ton.
|
||||
{
|
||||
name: "cargoTypeId",
|
||||
label: "Bulk cargo type",
|
||||
type: "select",
|
||||
required: true,
|
||||
placeholder: "Which bulk commodity this rate covers",
|
||||
showIf: (v) =>
|
||||
hasShippingLine(v) &&
|
||||
v.shippingLineRateKind === "BASE" &&
|
||||
v.shippingLineCargoKind === "BULK",
|
||||
},
|
||||
// ── The leg this rate prices — base freight only ──────────────────────
|
||||
// Options are narrowed to the countries the direction allows (import
|
||||
|
||||
@@ -20,6 +20,11 @@ export interface RuleEngineListParams {
|
||||
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
|
||||
appliesTo?: string;
|
||||
trigger?: string;
|
||||
/**
|
||||
* Rates only: "true" lists shipping-line rates, "false" standard customer
|
||||
* ones. Omitted lists both.
|
||||
*/
|
||||
isShippingLineRate?: string;
|
||||
}
|
||||
|
||||
export interface RuleEngineReorderPayload {
|
||||
@@ -214,6 +219,7 @@ export const ruleEngineService = {
|
||||
requiresDirectorApproval: params?.requiresDirectorApproval,
|
||||
appliesTo: params?.appliesTo,
|
||||
trigger: params?.trigger,
|
||||
isShippingLineRate: params?.isShippingLineRate,
|
||||
},
|
||||
});
|
||||
return normalizeList<T>(response.data, page, pageSize);
|
||||
|
||||
Reference in New Issue
Block a user