mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 19:28:17 +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);
|
||||
|
||||
@@ -60,6 +60,7 @@ import PaymentFailurePage from "./pages/payments/PaymentFailurePage";
|
||||
import FaydaCallbackPage from "./pages/FaydaCallbackPage";
|
||||
import PaymentSuccessPage from "./pages/payments/PaymentSuccessPage";
|
||||
import {
|
||||
ShippingLineBookingDetailPage,
|
||||
ShippingLineBookingsPage,
|
||||
ShippingLineHelpPage,
|
||||
ShippingLineHomePage,
|
||||
@@ -418,6 +419,9 @@ const App = () => {
|
||||
onNavigate={navigate}
|
||||
userName={displayName}
|
||||
userEmail={userEmail}
|
||||
// Support chat is company-scoped; a shipping line has no
|
||||
// company, so every poll would 403.
|
||||
showSupportWidget={false}
|
||||
>
|
||||
<Outlet />
|
||||
</AppLayout>
|
||||
@@ -431,6 +435,10 @@ const App = () => {
|
||||
path="/shipping-line/bookings"
|
||||
element={<ShippingLineBookingsPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/bookings/:id"
|
||||
element={<ShippingLineBookingDetailPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/shipping-line/invoices"
|
||||
element={<ShippingLineInvoicesPage />}
|
||||
|
||||
@@ -67,6 +67,12 @@ export interface AppLayoutProps {
|
||||
}[];
|
||||
/** Company type (e.g. "customer", "forwarder") — gates the "Add service" control. */
|
||||
companyType?: string | null;
|
||||
/**
|
||||
* Render the floating support-chat launcher. Defaults to true so the customer
|
||||
* portal is unaffected; shipping lines pass false — support chat is scoped to
|
||||
* a company, which they do not have.
|
||||
*/
|
||||
showSupportWidget?: boolean;
|
||||
/** Create a new service profile of the given type (with business license). */
|
||||
onCreateProfile?: (
|
||||
type: ServiceType,
|
||||
@@ -152,6 +158,7 @@ export function AppLayout({
|
||||
companyType,
|
||||
onCreateProfile,
|
||||
onReapplyProfile,
|
||||
showSupportWidget = true,
|
||||
children,
|
||||
}: AppLayoutProps) {
|
||||
const [mobileOpen, { toggle: toggleMobile }] = useDisclosure();
|
||||
@@ -859,8 +866,10 @@ export function AppLayout({
|
||||
{children}
|
||||
</AppShell.Main>
|
||||
|
||||
{/* Floating customer-support chat launcher. */}
|
||||
<SupportWidget />
|
||||
{/* Floating customer-support chat launcher. Hidden when the caller opts
|
||||
out: support chat resolves the user's external profile → company, and
|
||||
a shipping line has neither, so every poll would 403. */}
|
||||
{showSupportWidget && <SupportWidget />}
|
||||
|
||||
{/* Create-profile modal — opens when switching to a mode the company
|
||||
doesn't have a profile for yet. */}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
ClipboardList,
|
||||
Clock,
|
||||
FileText,
|
||||
Package,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
|
||||
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||
import {
|
||||
BodyGrid,
|
||||
CardTitle,
|
||||
PageShell,
|
||||
SectionCard,
|
||||
} from "@/pages/bookings/BookingDetailPage/components/layout";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
import {
|
||||
bookingDocState,
|
||||
hasDocuments,
|
||||
needsUpload,
|
||||
DOC_STATE_ACTION_LABEL,
|
||||
DOC_STATE_COLOR,
|
||||
DOC_STATE_LABEL,
|
||||
} from "./booking-doc-state";
|
||||
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||
|
||||
/**
|
||||
* Statuses a booking can be cancelled from — everything before it is priced.
|
||||
* Mirrors SHIPPING_LINE_CANCELLABLE_STATUSES on the API.
|
||||
*/
|
||||
const CANCELLABLE_STATUSES = new Set([
|
||||
"AWAITING_DOCUMENTS",
|
||||
"DOCUMENTS_UNDER_REVIEW",
|
||||
"CLEARANCE_READY",
|
||||
"CHANGES_REQUESTED",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Shipping-line booking detail.
|
||||
*
|
||||
* Shaped like the customer booking detail page — same shell, same two-column
|
||||
* body — but split into Documents / Booking details tabs, since a bare
|
||||
* shipping-line booking has little else to show until it is completed.
|
||||
*/
|
||||
export default function ShippingLineBookingDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [docsOpen, setDocsOpen] = useState(false);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelReason, setCancelReason] = useState("");
|
||||
|
||||
const bookingQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings", id],
|
||||
queryFn: () => shippingLineBookingsService.getById(id),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: () => shippingLineBookingsService.cancel(id, cancelReason),
|
||||
onSuccess: () => {
|
||||
setCancelOpen(false);
|
||||
setCancelReason("");
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (bookingQuery.isLoading) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Center py={80}>
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (bookingQuery.isError || !bookingQuery.data) {
|
||||
return (
|
||||
<PageShell>
|
||||
<Alert color="red">This booking could not be loaded.</Alert>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
const booking: ShippingLineBooking = bookingQuery.data;
|
||||
const status = booking.status as string;
|
||||
const docState = bookingDocState(booking);
|
||||
const showDocs = hasDocuments(docState);
|
||||
const wantsUpload = needsUpload(docState);
|
||||
const actionNeeded = docState === "ACTION_NEEDED";
|
||||
|
||||
// Mirrors the server's rule: cancellable only before the booking is priced.
|
||||
// Kept in sync deliberately — a button the API would reject is worse than no
|
||||
// button at all.
|
||||
const canCancel =
|
||||
CANCELLABLE_STATUSES.has(status) && !(Number(booking.totalAmount ?? 0) > 0);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="compact-sm"
|
||||
w="fit-content"
|
||||
leftSection={<ArrowLeft size={15} />}
|
||||
onClick={() => navigate("/shipping-line/bookings")}
|
||||
>
|
||||
Back to bookings
|
||||
</Button>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Stack gap={8} miw={0}>
|
||||
<Text fz="26px" fw={800} c="#10202F">
|
||||
{booking.reference}
|
||||
</Text>
|
||||
<Group gap={8}>
|
||||
<StatusBadge status={status} />
|
||||
{showDocs && (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={DOC_STATE_COLOR[docState]}
|
||||
leftSection={
|
||||
actionNeeded ? <AlertCircle size={12} /> : undefined
|
||||
}
|
||||
>
|
||||
{DOC_STATE_LABEL[docState]}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
<Group gap="sm">
|
||||
{canCancel && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={16} />}
|
||||
onClick={() => setCancelOpen(true)}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
)}
|
||||
{showDocs && (
|
||||
<Button
|
||||
color={actionNeeded ? "red" : "edr-green"}
|
||||
variant={wantsUpload ? "filled" : "light"}
|
||||
radius="md"
|
||||
leftSection={
|
||||
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
|
||||
}
|
||||
onClick={() => setDocsOpen(true)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[docState]}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Tabs defaultValue="documents" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab
|
||||
value="documents"
|
||||
leftSection={<FileText size={15} />}
|
||||
// The one place a query surfaces without opening the tab.
|
||||
rightSection={
|
||||
actionNeeded ? (
|
||||
<Badge size="xs" circle variant="filled" color="red">
|
||||
!
|
||||
</Badge>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
Documents
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="details" leftSection={<Package size={15} />}>
|
||||
Booking details
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="documents" pt="lg">
|
||||
<SectionCard>
|
||||
<CardTitle>Documents</CardTitle>
|
||||
|
||||
<Stack gap="md" mt="sm">
|
||||
{docState === "ACTION_NEEDED" ? (
|
||||
<Alert color="red" radius="md" icon={<AlertCircle size={18} />}>
|
||||
Operations returned one or more documents with a query. Open
|
||||
the documents, read the note on each flagged item and upload a
|
||||
corrected file — the booking cannot move on until you do.
|
||||
</Alert>
|
||||
) : docState === "AWAITING" ? (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<ClipboardList size={18} />}
|
||||
>
|
||||
This booking is waiting on your documents. Upload them for
|
||||
Operations to review — the booking can be completed once they
|
||||
are approved.
|
||||
</Alert>
|
||||
) : docState === "IN_REVIEW" ? (
|
||||
<Alert color="blue" radius="md" icon={<Clock size={18} />}>
|
||||
Your documents are with Operations for review. You can still
|
||||
open them, and replace any that come back with a query.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
>
|
||||
Your documents are approved.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color={actionNeeded ? "red" : "edr-green"}
|
||||
variant={wantsUpload ? "filled" : "light"}
|
||||
radius="md"
|
||||
w="fit-content"
|
||||
leftSection={
|
||||
wantsUpload ? <Upload size={16} /> : <FileText size={16} />
|
||||
}
|
||||
onClick={() => setDocsOpen(true)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[docState]}
|
||||
</Button>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="details" pt="lg">
|
||||
<BodyGrid
|
||||
left={
|
||||
<SectionCard>
|
||||
<CardTitle>Shipment</CardTitle>
|
||||
<Stack gap="xs" mt="sm">
|
||||
{/* Set at initiate time from the chosen route, so it is
|
||||
known before Operations reviews the documents. */}
|
||||
<DetailRow
|
||||
label="Route"
|
||||
value={
|
||||
booking.originYard || booking.destinationYard
|
||||
? `${
|
||||
booking.originYard?.label ??
|
||||
booking.originYard?.code ??
|
||||
"—"
|
||||
} → ${
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
"—"
|
||||
}`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Freight type"
|
||||
value={booking.freightType ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Trade direction"
|
||||
value={booking.tradeDirection ?? "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Shipment day"
|
||||
value={
|
||||
booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toLocaleDateString()
|
||||
: "Not scheduled yet"
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
}
|
||||
right={
|
||||
<SectionCard>
|
||||
<CardTitle>Booking</CardTitle>
|
||||
<Stack gap="xs" mt="sm">
|
||||
<DetailRow label="Reference" value={booking.reference} />
|
||||
<DetailRow label="Status" value={status} />
|
||||
<DetailRow
|
||||
label="Created"
|
||||
value={
|
||||
booking.createdAt
|
||||
? new Date(booking.createdAt).toLocaleDateString()
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</SectionCard>
|
||||
}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<ShippingLineDocumentsModal
|
||||
booking={docsOpen ? booking : null}
|
||||
onClose={() => setDocsOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Cancelling is irreversible, so it asks first rather than firing on the
|
||||
button press. The reason is optional but recorded. */}
|
||||
<Modal
|
||||
opened={cancelOpen}
|
||||
onClose={() => setCancelOpen(false)}
|
||||
centered
|
||||
radius="md"
|
||||
title={
|
||||
<Text fw={700} fz={16}>
|
||||
Cancel this booking?
|
||||
</Text>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Alert color="red" variant="light" icon={<AlertCircle size={18} />}>
|
||||
Booking <strong>{booking.reference}</strong> will be cancelled. This
|
||||
cannot be undone — you would need to initiate a new booking.
|
||||
</Alert>
|
||||
|
||||
<Textarea
|
||||
label="Reason (optional)"
|
||||
placeholder="Why are you cancelling this booking?"
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={4}
|
||||
maxLength={500}
|
||||
value={cancelReason}
|
||||
onChange={(e) => setCancelReason(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
{cancelMutation.isError && (
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
{(cancelMutation.error as Error)?.message ??
|
||||
"Could not cancel the booking."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
radius="md"
|
||||
onClick={() => setCancelOpen(false)}
|
||||
>
|
||||
Keep booking
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
radius="md"
|
||||
leftSection={<Ban size={16} />}
|
||||
loading={cancelMutation.isPending}
|
||||
onClick={() => cancelMutation.mutate()}
|
||||
>
|
||||
Cancel booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-start" gap="md" wrap="nowrap">
|
||||
<Text fz={13} c="edr-muted">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={13} fw={600} ta="right">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,314 @@
|
||||
import { Package } from "lucide-react";
|
||||
import ShippingLinePlaceholder from "./ShippingLinePlaceholder";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Menu,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
DataTable,
|
||||
DataTableFooter,
|
||||
type ColumnDef,
|
||||
usePagination,
|
||||
} from "@edr/ui-common";
|
||||
import {
|
||||
AlertCircle,
|
||||
FileText,
|
||||
MoreVertical,
|
||||
Package,
|
||||
Plus,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* Shipping-line bookings. Contracts do not apply to shipping lines, so a
|
||||
* booking is requested directly here rather than being created against a
|
||||
* contract the way the customer flow does it.
|
||||
*/
|
||||
export default function ShippingLineBookingsPage() {
|
||||
import { BookingStatusBadge as StatusBadge } from "@/pages/bookings/booking-display";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
import {
|
||||
bookingDocState,
|
||||
hasDocuments,
|
||||
needsUpload,
|
||||
DOC_STATE_ACTION_LABEL,
|
||||
DOC_STATE_COLOR,
|
||||
DOC_STATE_LABEL,
|
||||
} from "./booking-doc-state";
|
||||
import ShippingLineDocumentsModal from "./ShippingLineDocumentsModal";
|
||||
import ShippingLineInitiateModal from "./ShippingLineInitiateModal";
|
||||
|
||||
function ColHeader({ label }: { label: string }) {
|
||||
return (
|
||||
<ShippingLinePlaceholder
|
||||
title="Bookings"
|
||||
description="Request and track your booking requests."
|
||||
icon={<Package size={28} className="text-slate-300" />}
|
||||
/>
|
||||
<Text fz={12} fw={700} c="edr-muted" tt="uppercase" lts="0.04em">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipping-line bookings list.
|
||||
*
|
||||
* Mirrors the customer bookings list (same DataTable, status badge, row-click
|
||||
* to detail and per-row action menu) so the two portals read the same. What
|
||||
* differs is the flow behind it: contracts do not apply to shipping lines, so
|
||||
* "Initiate booking" creates a bare booking directly rather than routing
|
||||
* through a contract.
|
||||
*/
|
||||
export default function ShippingLineBookingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { pagination, setPagination } = usePagination();
|
||||
const [docsBooking, setDocsBooking] = useState<ShippingLineBooking | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const bookingsQuery = useQuery({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
queryFn: shippingLineBookingsService.list,
|
||||
});
|
||||
|
||||
const [initiateOpen, setInitiateOpen] = useState(false);
|
||||
|
||||
const rows = useMemo(() => bookingsQuery.data ?? [], [bookingsQuery.data]);
|
||||
|
||||
const status = bookingsQuery.isLoading
|
||||
? "loading"
|
||||
: bookingsQuery.isError
|
||||
? "error"
|
||||
: "success";
|
||||
const showEmpty = status === "success" && rows.length === 0;
|
||||
|
||||
const columns: ColumnDef<ShippingLineBooking>[] = [
|
||||
{
|
||||
id: "reference",
|
||||
header: () => <ColHeader label="Booking" />,
|
||||
cell: ({ row }) => (
|
||||
<Text fw={700} fz={14}>
|
||||
{row.original.reference}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "route",
|
||||
header: () => <ColHeader label="Route" />,
|
||||
cell: ({ row }) => {
|
||||
const b = row.original;
|
||||
const origin = b.originYard?.label ?? b.originYard?.code;
|
||||
const dest = b.destinationYard?.label ?? b.destinationYard?.code;
|
||||
return (
|
||||
<Text fz={13} c={origin && dest ? undefined : "edr-muted"}>
|
||||
{origin && dest ? `${origin} → ${dest}` : "—"}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: () => <ColHeader label="Status" />,
|
||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
header: () => <ColHeader label="Documents" />,
|
||||
cell: ({ row }) => {
|
||||
const state = bookingDocState(row.original);
|
||||
if (state === "NONE") return null;
|
||||
return (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
radius="sm"
|
||||
color={DOC_STATE_COLOR[state]}
|
||||
leftSection={
|
||||
state === "ACTION_NEEDED" ? <AlertCircle size={12} /> : undefined
|
||||
}
|
||||
>
|
||||
{DOC_STATE_LABEL[state]}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created",
|
||||
header: () => <ColHeader label="Created" />,
|
||||
cell: ({ row }) => (
|
||||
<Text fz={13} c="edr-muted">
|
||||
{row.original.createdAt
|
||||
? new Date(row.original.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 200,
|
||||
header: () => null,
|
||||
cell: ({ row }) => {
|
||||
const booking = row.original;
|
||||
const state = bookingDocState(booking);
|
||||
const showDocs = hasDocuments(state);
|
||||
const wantsUpload = needsUpload(state);
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={6}
|
||||
justify="flex-end"
|
||||
wrap="nowrap"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showDocs && (
|
||||
<Button
|
||||
size="xs"
|
||||
radius="md"
|
||||
variant="light"
|
||||
// A queried document is the one case that needs to pull the
|
||||
// eye — it is the only state where the shipping line is
|
||||
// blocking its own booking.
|
||||
color={state === "ACTION_NEEDED" ? "red" : "edr-green"}
|
||||
fw={700}
|
||||
fz={13}
|
||||
leftSection={
|
||||
wantsUpload ? <Upload size={14} /> : <FileText size={14} />
|
||||
}
|
||||
onClick={() => setDocsBooking(booking)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[state]}
|
||||
</Button>
|
||||
)}
|
||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
size={30}
|
||||
radius="md"
|
||||
aria-label="More options"
|
||||
>
|
||||
<MoreVertical size={16} color="#9AA8B5" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
navigate(`/shipping-line/bookings/${booking.id}`)
|
||||
}
|
||||
>
|
||||
View details
|
||||
</Menu.Item>
|
||||
{showDocs && (
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
wantsUpload ? (
|
||||
<Upload size={15} />
|
||||
) : (
|
||||
<FileText size={15} />
|
||||
)
|
||||
}
|
||||
onClick={() => setDocsBooking(booking)}
|
||||
>
|
||||
{DOC_STATE_ACTION_LABEL[state]}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Box style={{ padding: "28px 32px 32px" }}>
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="md">
|
||||
<Box>
|
||||
<Title
|
||||
order={1}
|
||||
fw={800}
|
||||
fz={26}
|
||||
style={{ letterSpacing: "-0.01em" }}
|
||||
>
|
||||
Bookings
|
||||
</Title>
|
||||
<Text size="sm" c="edr-muted" mt={4}>
|
||||
Request a booking directly — no contract required.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setInitiateOpen(true)}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Card radius={16} p={0} withBorder style={{ borderColor: "#E6ECF2" }}>
|
||||
{showEmpty ? (
|
||||
<Stack align="center" gap="xs" py={64}>
|
||||
<Package size={28} className="text-slate-300" />
|
||||
<Text c="edr-muted" size="sm">
|
||||
No bookings yet — initiate one to get started.
|
||||
</Text>
|
||||
<Button
|
||||
size="sm"
|
||||
mt="md"
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
onClick={() => setInitiateOpen(true)}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
status={status}
|
||||
onRowClick={(row) =>
|
||||
navigate(
|
||||
`/shipping-line/bookings/${(row as ShippingLineBooking).id}`,
|
||||
)
|
||||
}
|
||||
pagination={{
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
pageCount: Math.max(
|
||||
1,
|
||||
Math.ceil(rows.length / pagination.pageSize),
|
||||
),
|
||||
totalCount: rows.length,
|
||||
}}
|
||||
tableOptions={{
|
||||
state: { pagination },
|
||||
onPaginationChange: setPagination,
|
||||
}}
|
||||
containerClassName="border-0 shadow-none rounded-none"
|
||||
footer={DataTableFooter}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Stack>
|
||||
|
||||
<ShippingLineInitiateModal
|
||||
opened={initiateOpen}
|
||||
onClose={() => setInitiateOpen(false)}
|
||||
onCreated={(booking) => {
|
||||
setInitiateOpen(false);
|
||||
// Straight into the new booking — documents are the next thing owed.
|
||||
navigate(`/shipping-line/bookings/${booking.id}`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ShippingLineDocumentsModal
|
||||
booking={docsBooking}
|
||||
onClose={() => setDocsBooking(null)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, CheckCircle2, Clock, Upload } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { ClearanceDocumentUploadCard } from "@/components/contracts/ClearanceDocumentUploadCard";
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { shippingLineBookingsService } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
/**
|
||||
* Document upload for a shipping-line booking.
|
||||
*
|
||||
* Deliberately the same surface as the customer clearance modal
|
||||
* (`BookingActionModal`): the grid is built from the booking's clearance view,
|
||||
* so every field shows its uploaded file, its review badge and the reviewer's
|
||||
* note, and can be replaced in place — an approved document is locked, exactly
|
||||
* as it is for customers.
|
||||
*/
|
||||
export default function ShippingLineDocumentsModal({
|
||||
booking,
|
||||
onClose,
|
||||
}: {
|
||||
booking: ShippingLineBooking | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { view, viewer } = useFileViewer();
|
||||
// Files picked but not yet submitted, keyed by document field.
|
||||
const [pending, setPending] = useState<Record<string, File>>({});
|
||||
|
||||
// Each booking gets a fresh sheet — otherwise files staged for one booking
|
||||
// would still be attached when the modal reopens on another.
|
||||
useEffect(() => {
|
||||
setPending({});
|
||||
}, [booking?.id]);
|
||||
|
||||
const clearanceQuery = useQuery({
|
||||
queryKey: ["shipping-line-clearance", booking?.id],
|
||||
queryFn: () => shippingLineBookingsService.getClearance(booking!.id),
|
||||
enabled: booking !== null,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
shippingLineBookingsService.uploadDocuments(booking!.id, pending),
|
||||
onSuccess: () => {
|
||||
setPending({});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-clearance", booking?.id],
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const clearance = clearanceQuery.data;
|
||||
const status = clearance?.status ?? booking?.status ?? "";
|
||||
|
||||
// The fields this booking asks for. `uploadedBy: "gl"` rows are staff output
|
||||
// documents, which the uploader never fills in.
|
||||
const docs = useMemo(
|
||||
() => (clearance?.documents ?? []).filter((d) => d.uploadedBy !== "gl"),
|
||||
[clearance],
|
||||
);
|
||||
|
||||
// Uploads are accepted while the booking is awaiting documents or already in
|
||||
// review (fixing a queried one) — matching the server's own status gate.
|
||||
const canUpload =
|
||||
status === "AWAITING_DOCUMENTS" || status === "DOCUMENTS_UNDER_REVIEW";
|
||||
const isInitialUpload = status === "AWAITING_DOCUMENTS";
|
||||
|
||||
// Documents a reviewer sent back. These are what the shipping line has to
|
||||
// act on, and the reason the modal leads with a red banner rather than the
|
||||
// neutral "in review" one.
|
||||
const queriedDocs = docs.filter((d) => d.reviewStatus === "QUERIED");
|
||||
|
||||
const missingRequired = docs.filter(
|
||||
(d) => d.required && !d.file && !pending[d.fileKey],
|
||||
);
|
||||
const hasStaged = Object.keys(pending).length > 0;
|
||||
|
||||
// First submission must cover every required field; later rounds only need
|
||||
// the specific documents being corrected.
|
||||
const canSubmit = isInitialUpload
|
||||
? hasStaged && missingRequired.length === 0
|
||||
: hasStaged;
|
||||
|
||||
const stage = (fileKey: string, file: File | null) =>
|
||||
setPending((p) => {
|
||||
if (file) return { ...p, [fileKey]: file };
|
||||
const next = { ...p };
|
||||
delete next[fileKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
opened={booking !== null}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="xl"
|
||||
radius="md"
|
||||
title={
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Booking documents
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed" ff="monospace">
|
||||
{booking?.reference}
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
styles={{ body: { paddingTop: 8 } }}
|
||||
>
|
||||
{clearanceQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : docs.length === 0 ? (
|
||||
<Text fz="13px" c="dimmed" py="md">
|
||||
No document requirements are configured yet. Staff set these up in
|
||||
the backoffice under Settings → File settings.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{queriedDocs.length > 0 ? (
|
||||
<Alert
|
||||
color="red"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
{queriedDocs.length === 1
|
||||
? `"${queriedDocs[0].label}" was returned with a query. `
|
||||
: `${queriedDocs.length} documents were returned with a query. `}
|
||||
Read the note on each flagged document below and upload a
|
||||
corrected file.
|
||||
</Alert>
|
||||
) : status === "DOCUMENTS_UNDER_REVIEW" ? (
|
||||
<Alert
|
||||
color="blue"
|
||||
radius="md"
|
||||
icon={<Clock size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Our team is reviewing your documents. Only re-upload the
|
||||
documents flagged with a query below — approved documents stay
|
||||
as they are.
|
||||
</Alert>
|
||||
) : status === "CLEARANCE_READY" ? (
|
||||
<Alert
|
||||
color="teal"
|
||||
radius="md"
|
||||
icon={<CheckCircle2 size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Your documents are approved.
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert
|
||||
color="yellow"
|
||||
radius="md"
|
||||
icon={<AlertCircle size={18} />}
|
||||
mb="md"
|
||||
>
|
||||
Upload every required document (marked *) below to start the
|
||||
review.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{isInitialUpload && missingRequired.length > 0 && (
|
||||
<Alert color="yellow" variant="light" radius="md" mb="md" p="xs">
|
||||
<Text fz="12px" c="#9A5B00">
|
||||
Still required:{" "}
|
||||
{missingRequired.map((d) => d.label).join(", ")}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stack gap="md">
|
||||
{docs.map((doc) => (
|
||||
<ClearanceDocumentUploadCard
|
||||
key={doc.fileKey}
|
||||
label={doc.label}
|
||||
required={doc.required}
|
||||
reviewStatus={doc.reviewStatus ?? undefined}
|
||||
note={doc.note}
|
||||
uploadedFile={doc.file}
|
||||
stagedFile={pending[doc.fileKey] ?? null}
|
||||
canUpload={canUpload}
|
||||
// An approved document is final — same rule as the customer
|
||||
// flow, so it renders read-only with just a preview.
|
||||
onStageFile={
|
||||
canUpload && doc.reviewStatus !== "APPROVED"
|
||||
? (file) => stage(doc.fileKey, file)
|
||||
: undefined
|
||||
}
|
||||
onPreview={view}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{uploadMutation.isError && (
|
||||
<Alert color="red" mt="md" icon={<AlertCircle size={16} />}>
|
||||
{(uploadMutation.error as Error)?.message ??
|
||||
"Could not upload the documents."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xl" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{canUpload && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
loading={uploadMutation.isPending}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Submit documents
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
{viewer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertCircle, MapPin, Plus } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
shippingLineBookingsService,
|
||||
type ShippingLineBooking,
|
||||
} from "@/services/shipping-line-bookings.service";
|
||||
|
||||
const FREIGHT_TYPES = [
|
||||
{ value: "CONTAINER", label: "Container" },
|
||||
{ value: "BULK", label: "Bulk" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Initiate a shipping-line booking.
|
||||
*
|
||||
* Origin and destination are picked separately (matching the customer form),
|
||||
* but both lists are drawn from real routes, so the pair always resolves to a
|
||||
* lane EDR runs. The request still sends that route's id, letting the server
|
||||
* derive origin, destination and direction from one authoritative row.
|
||||
*
|
||||
* Only inbound (Djibouti to Ethiopia) lanes are offered — the API filters them
|
||||
* and rejects anything else, so this is a fixed rule, not a UI convenience.
|
||||
*/
|
||||
export default function ShippingLineInitiateModal({
|
||||
opened,
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onCreated: (booking: ShippingLineBooking) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
const [freightType, setFreightType] = useState<string>("CONTAINER");
|
||||
|
||||
// Fresh sheet each time it opens.
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setOriginYardId(null);
|
||||
setDestinationYardId(null);
|
||||
setServiceTypeId(null);
|
||||
setFreightType("CONTAINER");
|
||||
}
|
||||
}, [opened]);
|
||||
|
||||
const referenceQuery = useQuery({
|
||||
queryKey: ["shipping-line-reference-data"],
|
||||
queryFn: shippingLineBookingsService.referenceData,
|
||||
enabled: opened,
|
||||
});
|
||||
|
||||
const initiateMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
shippingLineBookingsService.initiate({
|
||||
routeId: selectedRoute!.id,
|
||||
serviceTypeId: serviceTypeId ?? undefined,
|
||||
freightType,
|
||||
}),
|
||||
onSuccess: (booking) => {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["shipping-line-bookings"],
|
||||
});
|
||||
onCreated(booking);
|
||||
},
|
||||
});
|
||||
|
||||
const routes = useMemo(
|
||||
() => referenceQuery.data?.routes ?? [],
|
||||
[referenceQuery.data],
|
||||
);
|
||||
const serviceTypes = referenceQuery.data?.serviceTypes ?? [];
|
||||
|
||||
// Origin and destination are picked separately (as in the customer form), but
|
||||
// the pair still has to be a route EDR actually runs — so each list is drawn
|
||||
// from the routes, and the destination list narrows to what the chosen origin
|
||||
// can reach. That keeps the familiar two-field shape without letting someone
|
||||
// assemble a lane that does not exist.
|
||||
const originOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const route of routes) {
|
||||
if (!seen.has(route.originYardId)) {
|
||||
seen.set(route.originYardId, route.originLabel);
|
||||
}
|
||||
}
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [routes]);
|
||||
|
||||
const destinationOptions = useMemo(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const route of routes) {
|
||||
if (originYardId && route.originYardId !== originYardId) continue;
|
||||
if (!seen.has(route.destinationYardId)) {
|
||||
seen.set(route.destinationYardId, route.destinationLabel);
|
||||
}
|
||||
}
|
||||
return [...seen].map(([value, label]) => ({ value, label }));
|
||||
}, [routes, originYardId]);
|
||||
|
||||
// The lane the two picks resolve to. Still sent as a routeId so the server
|
||||
// keeps deriving origin/destination/direction from one authoritative row.
|
||||
const selectedRoute = routes.find(
|
||||
(r) =>
|
||||
r.originYardId === originYardId &&
|
||||
r.destinationYardId === destinationYardId,
|
||||
);
|
||||
|
||||
// Changing the origin can invalidate an already-picked destination.
|
||||
useEffect(() => {
|
||||
if (
|
||||
destinationYardId &&
|
||||
!destinationOptions.some((o) => o.value === destinationYardId)
|
||||
) {
|
||||
setDestinationYardId(null);
|
||||
}
|
||||
}, [destinationOptions, destinationYardId]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
centered
|
||||
size="md"
|
||||
radius="md"
|
||||
title={
|
||||
<Box>
|
||||
<Text fw={700} fz={16}>
|
||||
Initiate booking
|
||||
</Text>
|
||||
<Text fz={12} c="dimmed">
|
||||
Pick the lane — you will upload documents next.
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
|
||||
>
|
||||
{referenceQuery.isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : routes.length === 0 ? (
|
||||
<Text fz="13px" c="dimmed" py="md">
|
||||
No inbound routes are available for booking right now. Please contact
|
||||
Operations.
|
||||
</Text>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
<Group grow align="flex-start" gap="sm">
|
||||
<Select
|
||||
label="Origin yard (Djibouti)"
|
||||
placeholder="Select origin..."
|
||||
withAsterisk
|
||||
searchable
|
||||
data={originOptions}
|
||||
value={originYardId}
|
||||
onChange={setOriginYardId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
<Select
|
||||
label="Destination yard (Ethiopia)"
|
||||
placeholder="Select destination..."
|
||||
withAsterisk
|
||||
searchable
|
||||
disabled={!originYardId}
|
||||
data={destinationOptions}
|
||||
value={destinationYardId}
|
||||
onChange={setDestinationYardId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Shipping lines only move inbound cargo, so the direction is fixed
|
||||
rather than derived per pick — stated up front so the single
|
||||
option in each list does not read as missing data. */}
|
||||
<Alert
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
p="xs"
|
||||
icon={<MapPin size={15} />}
|
||||
>
|
||||
<Text fz={12}>
|
||||
Inbound only — cargo moves from Djibouti to Ethiopia (
|
||||
<Text span fw={700}>
|
||||
IMPORT
|
||||
</Text>
|
||||
).
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Select
|
||||
label="Freight type"
|
||||
data={FREIGHT_TYPES}
|
||||
value={freightType}
|
||||
onChange={(v) => setFreightType(v ?? "CONTAINER")}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
|
||||
{/* Only services that do NOT bundle customs are offered — the API
|
||||
filters them and rejects the rest. If none are configured the
|
||||
field says so rather than vanishing, which would read as a
|
||||
missing form rather than a data gap. */}
|
||||
{serviceTypes.length > 0 ? (
|
||||
<Select
|
||||
label="Service"
|
||||
placeholder="Select a service"
|
||||
clearable
|
||||
data={serviceTypes.map((s) => ({ value: s.id, label: s.name }))}
|
||||
value={serviceTypeId}
|
||||
onChange={setServiceTypeId}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
label="Service"
|
||||
placeholder="No non-customs service configured"
|
||||
disabled
|
||||
data={[]}
|
||||
value={null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{initiateMutation.isError && (
|
||||
<Alert color="red" icon={<AlertCircle size={16} />}>
|
||||
{(initiateMutation.error as Error)?.message ??
|
||||
"Could not initiate the booking."}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="sm" gap="sm">
|
||||
<Button variant="default" radius="md" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
leftSection={<Plus size={16} />}
|
||||
loading={initiateMutation.isPending}
|
||||
disabled={!selectedRoute}
|
||||
onClick={() => initiateMutation.mutate()}
|
||||
>
|
||||
Initiate booking
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ShippingLineBooking } from "@/services/shipping-line-bookings.service";
|
||||
|
||||
/**
|
||||
* What the shipping line has to do about a booking's documents.
|
||||
*
|
||||
* Derived from the booking status AND the per-document review results, because
|
||||
* the two disagree in the case that matters most: when a reviewer queries a
|
||||
* document, that document's review status becomes QUERIED but the BOOKING stays
|
||||
* on DOCUMENTS_UNDER_REVIEW. Keying the UI off status alone would keep showing
|
||||
* "In review" while the shipping line is actually being asked to fix something.
|
||||
*/
|
||||
export type BookingDocState =
|
||||
/** Nothing uploaded yet — the first submission is owed. */
|
||||
| "AWAITING"
|
||||
/** A reviewer sent something back; the shipping line must re-upload. */
|
||||
| "ACTION_NEEDED"
|
||||
/** Submitted and with Operations. */
|
||||
| "IN_REVIEW"
|
||||
/** Everything approved. */
|
||||
| "APPROVED"
|
||||
/** Documents do not apply at this status. */
|
||||
| "NONE";
|
||||
|
||||
export function bookingDocState(
|
||||
booking: Pick<ShippingLineBooking, "status" | "hasQueriedDocuments">,
|
||||
): BookingDocState {
|
||||
const status = booking.status as string;
|
||||
|
||||
// Checked before the status switch: a queried document outranks the booking's
|
||||
// own DOCUMENTS_UNDER_REVIEW, which is exactly the case status alone misses.
|
||||
if (booking.hasQueriedDocuments) return "ACTION_NEEDED";
|
||||
|
||||
switch (status) {
|
||||
case "AWAITING_DOCUMENTS":
|
||||
return "AWAITING";
|
||||
case "CHANGES_REQUESTED":
|
||||
return "ACTION_NEEDED";
|
||||
case "DOCUMENTS_UNDER_REVIEW":
|
||||
return "IN_REVIEW";
|
||||
case "CLEARANCE_READY":
|
||||
return "APPROVED";
|
||||
default:
|
||||
return "NONE";
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the document grid is worth opening at this state. */
|
||||
export function hasDocuments(state: BookingDocState): boolean {
|
||||
return state !== "NONE";
|
||||
}
|
||||
|
||||
/** Whether the shipping line owes an upload — drives the primary action label. */
|
||||
export function needsUpload(state: BookingDocState): boolean {
|
||||
return state === "AWAITING" || state === "ACTION_NEEDED";
|
||||
}
|
||||
|
||||
export const DOC_STATE_LABEL: Record<BookingDocState, string> = {
|
||||
AWAITING: "Documents needed",
|
||||
ACTION_NEEDED: "Action needed",
|
||||
IN_REVIEW: "In review",
|
||||
APPROVED: "Approved",
|
||||
NONE: "",
|
||||
};
|
||||
|
||||
/** Mantine colour for the state's badge/alert. */
|
||||
export const DOC_STATE_COLOR: Record<BookingDocState, string> = {
|
||||
AWAITING: "yellow",
|
||||
ACTION_NEEDED: "red",
|
||||
IN_REVIEW: "blue",
|
||||
APPROVED: "teal",
|
||||
NONE: "gray",
|
||||
};
|
||||
|
||||
/**
|
||||
* Label for the button that opens the document grid. A queried document asks
|
||||
* for a replacement, so it reads as an instruction rather than "View" — the
|
||||
* shipping line must swap the file, not just look at it.
|
||||
*/
|
||||
export const DOC_STATE_ACTION_LABEL: Record<BookingDocState, string> = {
|
||||
AWAITING: "Upload documents",
|
||||
ACTION_NEEDED: "Change document",
|
||||
IN_REVIEW: "View documents",
|
||||
APPROVED: "View documents",
|
||||
NONE: "View documents",
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as ShippingLineHomePage } from "./ShippingLineHomePage";
|
||||
export { default as ShippingLineBookingsPage } from "./ShippingLineBookingsPage";
|
||||
export { default as ShippingLineBookingDetailPage } from "./ShippingLineBookingDetailPage";
|
||||
export { default as ShippingLineInvoicesPage } from "./ShippingLineInvoicesPage";
|
||||
export { default as ShippingLineSettingsPage } from "./ShippingLineSettingsPage";
|
||||
export { default as ShippingLineHelpPage } from "./ShippingLineHelpPage";
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
|
||||
const BASE = "/api/shipping-line-bookings";
|
||||
|
||||
/**
|
||||
* The `code` of the file-upload setting whose fields a shipping line fills in
|
||||
* after initiating a booking. Configured in the backoffice file-settings editor
|
||||
* (Other tab) and seeded by `file-upload-settings.seeder.ts`.
|
||||
*/
|
||||
export const SHIPPING_LINE_BOOKING_DOCUMENTS_CODE =
|
||||
"shipping_line_booking_documents";
|
||||
|
||||
/**
|
||||
* A shipping-line booking as the portal sees it.
|
||||
*
|
||||
* `hasQueriedDocuments` is computed server-side: a reviewer querying a document
|
||||
* leaves the booking on DOCUMENTS_UNDER_REVIEW, so the booking status alone
|
||||
* cannot tell the UI that the shipping line has something to fix.
|
||||
*/
|
||||
export type ShippingLineBooking = Freight.IBooking & {
|
||||
hasQueriedDocuments?: boolean;
|
||||
};
|
||||
|
||||
/** A bookable lane. Its direction is frozen server-side from the yards. */
|
||||
export interface ShippingLineRouteOption {
|
||||
id: string;
|
||||
label: string;
|
||||
direction: string;
|
||||
originYardId: string;
|
||||
originLabel: string;
|
||||
destinationYardId: string;
|
||||
destinationLabel: string;
|
||||
}
|
||||
|
||||
export interface ShippingLineReferenceData {
|
||||
routes: ShippingLineRouteOption[];
|
||||
serviceTypes: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The route is the only lane input: it yields origin, destination and trade
|
||||
* direction together, so they cannot contradict each other.
|
||||
*/
|
||||
export interface InitiateShippingLineBookingPayload {
|
||||
routeId: string;
|
||||
serviceTypeId?: string;
|
||||
freightType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shipping-line bookings. Separate from `bookings.service.ts` (customers) — the
|
||||
* endpoints differ, and shipping lines book without a contract.
|
||||
*/
|
||||
export const shippingLineBookingsService = {
|
||||
/** Create a bare booking; it starts at AWAITING_DOCUMENTS. */
|
||||
initiate: async (
|
||||
payload: InitiateShippingLineBookingPayload,
|
||||
): Promise<ShippingLineBooking> => {
|
||||
const { data } = await client.post(`${BASE}/initiate`, payload);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/** Bookable routes + service types for the initiate form. */
|
||||
referenceData: async (): Promise<ShippingLineReferenceData> => {
|
||||
const { data } = await client.get(`${BASE}/reference-data`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
list: async (): Promise<ShippingLineBooking[]> => {
|
||||
const { data } = await client.get(`${BASE}/my`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<ShippingLineBooking> => {
|
||||
const { data } = await client.get(`${BASE}/${id}`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Cancel one of the signed-in shipping line's own bookings. Only accepted
|
||||
* before the booking is priced — the server enforces the same rule.
|
||||
*/
|
||||
cancel: async (id: string, reason?: string): Promise<ShippingLineBooking> => {
|
||||
const { data } = await client.post(`${BASE}/${id}/cancel`, { reason });
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* The document grid for a booking: every configured field with its uploaded
|
||||
* file, review status and reviewer note. Same shared endpoint the customer
|
||||
* clearance flow reads — it resolves the field set from the booking, which
|
||||
* now maps shipping-line bookings to their own file-upload setting.
|
||||
*/
|
||||
getClearance: async (id: string): Promise<Freight.ClearanceView> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/clearance`);
|
||||
return data.data ?? data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload the booking's documents.
|
||||
*
|
||||
* Posts to the shared CLEARANCE documents endpoint, not `/documents`: the
|
||||
* latter only accepts DRAFT bookings, and these start at AWAITING_DOCUMENTS.
|
||||
* This is the same endpoint the customer clearance flow uses — it files each
|
||||
* document for review and moves the booking to DOCUMENTS_UNDER_REVIEW.
|
||||
*/
|
||||
uploadDocuments: async (
|
||||
id: string,
|
||||
files: Record<string, File | File[] | null>,
|
||||
): Promise<ShippingLineBooking> => {
|
||||
const formData = new FormData();
|
||||
for (const [key, fileOrFiles] of Object.entries(files)) {
|
||||
if (!fileOrFiles) continue;
|
||||
if (Array.isArray(fileOrFiles)) {
|
||||
for (const f of fileOrFiles) formData.append(key, f);
|
||||
} else {
|
||||
formData.append(key, fileOrFiles);
|
||||
}
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`/api/bookings/${id}/clearance/documents`,
|
||||
formData,
|
||||
{ headers: { "Content-Type": "multipart/form-data" } },
|
||||
);
|
||||
return data.data ?? data;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user