mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
changes
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user