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:
marshalyordanos
2026-08-13 15:54:40 +03:00
parent 9aae132dd4
commit 9fff469ffa
50 changed files with 4485 additions and 77 deletions

View File

@@ -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}

View File

@@ -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

View File

@@ -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 ?? [];

View File

@@ -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}

View File

@@ -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

View File

@@ -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);

View File

@@ -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 />}

View File

@@ -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. */}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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",
};

View File

@@ -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";

View File

@@ -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;
},
};