This commit is contained in:
Marshal
2026-07-23 11:06:10 +00:00
parent 8f143b2341
commit 13609f8d59
26 changed files with 1717 additions and 115 deletions

View File

@@ -14,6 +14,8 @@ import {
} from "lucide-react";
import type { Freight } from "@edr/types";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { api } from "@/services/api";
import { contractsService } from "@/services/contracts.service";
import { SectionCard } from "@/components/bookings/detail/SectionCard";
@@ -48,8 +50,19 @@ export function ContractActionsToolbar({
onReviewClearance,
}: ContractActionsToolbarProps) {
const navigate = useNavigate();
const { user } = useAuth();
const { status } = contract;
// Intake permissions are split per freight type: an accept:bulk holder must
// not see the accept button on a container contract (API enforces the same).
const arm = contract.freightType === "BULK" ? "bulk" : "container";
const mayAccept = hasPermission(user, FREIGHT_PERMS.contracts.staffAccept[arm]);
const mayRequestChanges = hasPermission(
user,
FREIGHT_PERMS.contracts.requestChanges[arm],
);
const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]);
const [editorOpen, setEditorOpen] = useState(false);
const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept");
const [previewOpen, setPreviewOpen] = useState(false);
@@ -97,7 +110,8 @@ export function ContractActionsToolbar({
);
}
const canAccept = status === "SUBMITTED";
const canAccept =
status === "SUBMITTED" && (mayAccept || mayRequestChanges || mayReject);
// The document stays editable for the whole approval chain, but only by the
// approver whose turn it is. The server resolves that against the caller's
// position type; the client cannot derive it.
@@ -126,35 +140,41 @@ export function ContractActionsToolbar({
{canAccept && (
<>
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
{mayAccept && (
<Button
fullWidth
color="edr-green"
leftSection={<Check size={16} />}
onClick={() => {
setEditorMode("accept");
setEditorOpen(true);
}}
>
Accept for approval
</Button>
)}
{mayRequestChanges && (
<Button
fullWidth
variant="light"
color="orange"
leftSection={<MessageSquareWarning size={16} />}
onClick={() => setChangesOpen(true)}
>
Request changes
</Button>
)}
{mayReject && (
<Button
fullWidth
variant="light"
color="red"
leftSection={<XCircle size={16} />}
onClick={() => setRejectOpen(true)}
>
Reject contract
</Button>
)}
</>
)}

View File

@@ -29,9 +29,19 @@ export const FREIGHT_PERMS = {
},
contracts: {
view: "edr_freight_app:contracts:view",
staffAccept: "edr_freight_app:contracts:staff_accept",
requestChanges: "edr_freight_app:contracts:request_changes",
reject: "edr_freight_app:contracts:reject",
// Intake actions are split per freight type — mirror of the API registry.
staffAccept: {
bulk: "edr_freight_app:contracts:staff_accept:bulk",
container: "edr_freight_app:contracts:staff_accept:container",
},
requestChanges: {
bulk: "edr_freight_app:contracts:request_changes:bulk",
container: "edr_freight_app:contracts:request_changes:container",
},
reject: {
bulk: "edr_freight_app:contracts:reject:bulk",
container: "edr_freight_app:contracts:reject:container",
},
approveLineStaff: "edr_freight_app:contracts:approve_line_staff",
approveDirector: "edr_freight_app:contracts:approve_director",
approveCeo: "edr_freight_app:contracts:approve_ceo",

View File

@@ -13,6 +13,7 @@ import {
Loader,
Modal,
Stack,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -94,7 +95,14 @@ const yardOptionsForLegEnd = (
let country: string | undefined;
if (appliesTo === "INTERCITY") {
country = "Ethiopia";
} else if (appliesTo === "CONTAINER" || appliesTo === "BULK") {
} else if (
appliesTo === "CONTAINER" ||
appliesTo === "BULK" ||
// Customs clearance + empty-container return are sold per direction +
// route, so their yard dropdowns narrow exactly like base freight.
(appliesTo === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")))
) {
const direction = String(values.tradeDirection ?? "");
// Direction is what decides the countries, so offer nothing until it is set
// rather than defaulting to one and letting it read as a real choice.
@@ -124,6 +132,10 @@ const RuleEngineResourcePage = () => {
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [search, setSearch] = useState("");
// Category tabs (rates page): the active tab's filters go to the backend.
const [activeTab, setActiveTab] = useState<string>(
config?.listTabs?.[0]?.key ?? "",
);
const [formOpen, setFormOpen] = useState(false);
const [editing, setEditing] = useState<RuleEngineRecord | null>(null);
const [deleteTarget, setDeleteTarget] = useState<RuleEngineRecord | null>(
@@ -153,10 +165,13 @@ const RuleEngineResourcePage = () => {
sortOrder: "ASC" as const,
}
: {}),
...(config?.listTabs?.find((t) => t.key === activeTab)?.filters ?? {}),
}),
[
config?.orderConfig,
config?.supportsSearch,
config?.listTabs,
activeTab,
search,
pagination.pageIndex,
pagination.pageSize,
@@ -166,7 +181,8 @@ const RuleEngineResourcePage = () => {
useEffect(() => {
setPagination((prev) => ({ pageIndex: 0, pageSize: prev.pageSize }));
setSearch("");
}, [config?.slug, setPagination]);
setActiveTab(config?.listTabs?.[0]?.key ?? "");
}, [config?.slug, config?.listTabs, setPagination]);
const { data, isLoading, isError, error } = useRuleEngineList(
config?.slug ?? DEFAULT_CONFIGURATION_SLUG,
@@ -685,6 +701,25 @@ const RuleEngineResourcePage = () => {
<Card p={0}>
<Stack gap={0}>
{config.listTabs && (
<Tabs
value={activeTab}
onChange={(v) => {
setActiveTab(v ?? config.listTabs![0].key);
setPagination({ pageIndex: 0, pageSize: pagination.pageSize });
}}
px="md"
pt="sm"
>
<Tabs.List>
{config.listTabs.map((tab) => (
<Tabs.Tab key={tab.key} value={tab.key}>
{tab.label}
</Tabs.Tab>
))}
</Tabs.List>
</Tabs>
)}
<Box px="md" pt="md" pb="sm" w="100%">
<RuleEngineToolbar
search={search}

View File

@@ -84,6 +84,17 @@ export interface RuleEngineOrderConfig {
label: string;
}
/**
* A category tab above a resource list. The active tab's `filters` are sent to
* the list endpoint verbatim, so filtering happens server-side (values may be
* comma-separated lists, e.g. appliesTo: "FIRST_MILE,LAST_MILE").
*/
export interface RuleEngineListTab {
key: string;
label: string;
filters: { appliesTo?: string; trigger?: string };
}
export interface RuleEngineResourceConfig {
slug: RuleEngineResourceSlug;
label: string;
@@ -94,6 +105,8 @@ export interface RuleEngineResourceConfig {
formFields: FormFieldDef[];
supportsSearch?: boolean;
orderConfig?: RuleEngineOrderConfig;
/** Server-filtered category tabs rendered above the list (rates page). */
listTabs?: RuleEngineListTab[];
/** Primary line on card view (inferred from columns when omitted). */
cardTitleKey?: string;
/** Secondary line under title on card view (inferred when omitted). */
@@ -148,9 +161,15 @@ const RATE_APPLIES_TO = [
/** Surcharge triggers — only relevant when Applies to = Other. */
const RATE_TRIGGERS = [
{ label: "Hazardous cargo", value: "HAZARDOUS" },
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
{
label: "Overweight (export only — import derives from container price)",
value: "OVERWEIGHT",
},
{ label: "Reefer cargo", value: "REEFER" },
{ label: "Empty container return", value: "WITH_RETURN" },
{
label: "Empty container return (import, per route + container type)",
value: "WITH_RETURN",
},
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
{ label: "Consolidation", value: "CONSOLIDATION" },
{ label: "Lashing (flat, per booking)", value: "LASHING" },
@@ -175,6 +194,15 @@ const INTERCITY_KINDS = [
const isBaseFreightRate = (values: Record<string, unknown>) =>
["BULK", "CONTAINER", "INTERCITY"].includes(String(values.appliesTo ?? ""));
/**
* Rates priced per leg: base rail freight, plus the customs clearance fee and
* the empty-container return surcharge (sold per route + container type).
*/
const isRouteScopedRate = (values: Record<string, unknown>) =>
isBaseFreightRate(values) ||
(String(values.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(values.trigger ?? "")));
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
/**
@@ -604,6 +632,37 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
subtitle: "Freight rates and approval workflow",
searchPlaceholder: "Search rates by type or status...",
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" } },
{
key: "trucking",
label: "First / Last mile",
filters: { appliesTo: "FIRST_MILE,LAST_MILE" },
},
{
key: "customs",
label: "Customs clearance",
filters: { trigger: "CUSTOMS_CLEARANCE" },
},
{
key: "return",
label: "Container return",
filters: { trigger: "WITH_RETURN" },
},
{
key: "surcharges",
label: "Surcharges",
filters: {
appliesTo: "OTHER",
trigger:
"HAZARDOUS,OVERWEIGHT,REEFER,SHIPPING_LINE,CONSOLIDATION,LASHING,CANCELLATION,DEMURRAGE,PIL_EXTRA_FEE",
},
},
],
columns: [
{ id: "appliesTo", header: "Applies to", accessorKey: "appliesTo", format: "code" },
{ id: "trigger", header: "Trigger", accessorKey: "trigger" },
@@ -640,14 +699,23 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "What makes this surcharge apply?",
showWhen: { field: "appliesTo", equals: ["OTHER"] },
},
// ── Trade direction — Bulk & Container only (intercity is domestic) ───
// ── Trade direction — Bulk & Container base freight, plus the route-
// scoped surcharges (customs clearance; empty-container return, which is
// import-only for now so export is not offered) ────────────────────────
{
name: "tradeDirection",
label: "Trade direction",
type: "select",
required: true,
options: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showWhen: { field: "appliesTo", equals: ["BULK", "CONTAINER"] },
optionsFromValues: (v: Record<string, unknown>) =>
String(v.trigger ?? "") === "WITH_RETURN" &&
String(v.appliesTo ?? "") === "OTHER"
? TRADE_DIRECTIONS.filter((d) => d.value === "IMPORT")
: TRADE_DIRECTIONS.filter((d) => d.value !== "BOTH"),
showIf: (v) =>
["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
(String(v.appliesTo ?? "") === "OTHER" &&
["CUSTOMS_CLEARANCE", "WITH_RETURN"].includes(String(v.trigger ?? ""))),
},
// ── Cargo kind — Intercity only (import/export get it from appliesTo) ─
{
@@ -664,7 +732,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
getInitialValue: (record) =>
record.rateType === "INTERCITY_BULK" ? "BULK" : "CONTAINER",
},
// ── Container type — Container freight, and container-kind intercity ──
// ── Container type — Container freight, container-kind intercity, and
// the empty-container return surcharge (20ft vs 40ft price differently) ─
{
name: "containerTypeId",
label: "Container type",
@@ -673,7 +742,8 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
placeholder: "Select container type (optional)",
showIf: (v) =>
v.appliesTo === "CONTAINER" ||
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER"),
(v.appliesTo === "INTERCITY" && v.intercityKind === "CONTAINER") ||
(v.appliesTo === "OTHER" && v.trigger === "WITH_RETURN"),
},
// ── Bulk cargo (leaf commodity) — Bulk freight, and bulk-kind intercity ─
{
@@ -696,7 +766,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg starts",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{
name: "destinationYardId",
@@ -704,7 +774,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
type: "select",
required: true,
placeholder: "Where the leg ends",
showIf: isBaseFreightRate,
showIf: isRouteScopedRate,
},
{ name: "rateValue", label: "Rate value", type: "number", required: true, suffix: "USD" },
// Unit choices are driven by the rate shape (appliesTo + trigger). Overweight

View File

@@ -17,6 +17,9 @@ export interface RuleEngineListParams {
sortBy?: string;
sortOrder?: "ASC" | "DESC";
requiresDirectorApproval?: boolean;
/** Rates category tabs — comma-separated appliesTo / trigger filters. */
appliesTo?: string;
trigger?: string;
}
export interface RuleEngineReorderPayload {
@@ -205,6 +208,8 @@ export const ruleEngineService = {
sortBy: params?.sortBy,
sortOrder: params?.sortOrder,
requiresDirectorApproval: params?.requiresDirectorApproval,
appliesTo: params?.appliesTo,
trigger: params?.trigger,
},
});
return normalizeList<T>(response.data, page, pageSize);