Merge pull request #761 from Tria-plc/freight_feature/usermanagement

fix rate edit
This commit is contained in:
marshal
2026-07-17 13:31:08 +03:00
committed by GitHub
20 changed files with 1227 additions and 112 deletions

View File

@@ -167,6 +167,7 @@ export const QUERY_KEYS = {
orderList: (resource: RuleEngineResourceSlug | string) =>
["rule-engine", "order-list", resource] as const,
priorityRuleChanges: ["rule-engine", "priority-rule-changes"] as const,
rateChanges: ["rule-engine", "rate-changes"] as const,
},
OVERVIEW: {

View File

@@ -7,6 +7,7 @@ import {
ruleEngineService,
type RuleEngineListParams,
type SubmitPriorityRuleChangePayload,
type SubmitRateChangePayload,
} from "@/services/ruleEngine/ruleEngine.service";
import { RULE_ENGINE_SELECT_NONE } from "@/pages/ruleEngine/config/resources";
import type {
@@ -318,6 +319,71 @@ export const usePriorityRuleWorkflow = (
return { pending, submit, approve, reject };
};
/**
* Approval workflow for edits to LIVE rates. The live rate keeps its current
* value until a change is approved, so the rates list is invalidated on every
* outcome — including reject, which restores the row's "no pending" state.
*/
export const useRateChangeWorkflow = (
enabled: boolean,
onErrorMessage?: (message: string) => void,
) => {
const qc = useQueryClient();
const showError = (err: unknown, fallback: string) => {
const raw = (err as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
const message = (Array.isArray(raw) ? raw.join(", ") : raw) || fallback;
if (onErrorMessage) onErrorMessage(message);
else toast.error(message);
};
const pending = useQuery({
queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges,
queryFn: () => ruleEngineService.listRateChanges("PENDING"),
enabled,
});
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: QUERY_KEYS.RULE_ENGINE.rateChanges });
await invalidateRuleEngineList(qc, "rates");
};
const submit = useMutation({
mutationFn: (payload: SubmitRateChangePayload) =>
ruleEngineService.submitRateChange(payload),
onSuccess: async () => {
toast.success(
"Change submitted for approval — the rate keeps its current value until approved",
);
await invalidate();
},
onError: (err) => showError(err, "Failed to submit rate change"),
});
const approve = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.approveRateChange(id, decisionNote),
onSuccess: async () => {
toast.success("Rate change approved — the new rate is now live");
await invalidate();
},
onError: (err) => showError(err, "Failed to approve rate change"),
});
const reject = useMutation({
mutationFn: ({ id, decisionNote }: { id: string; decisionNote?: string }) =>
ruleEngineService.rejectRateChange(id, decisionNote),
onSuccess: async () => {
toast.success("Rate change rejected — the rate keeps its current value");
await invalidate();
},
onError: (err) => showError(err, "Failed to reject rate change"),
});
return { pending, submit, approve, reject };
};
export const useRateWorkflow = () => {
const qc = useQueryClient();

View File

@@ -446,6 +446,21 @@ export function ruleEngineManageKey(slug: RuleEngineResourceSlug): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:manage`;
}
/**
* Deciding a filed change — a step above `manage`, which only lets a staff
* member propose one. Only resources with an approval workflow have it.
*/
export function ruleEngineApproveKey(slug: "rates"): string {
return `edr_freight_app:rule_engine:${slugToResourceKey(slug)}:approve`;
}
export function canApproveRuleEngineChange(
user: AuthUser | null | undefined,
slug: "rates",
): boolean {
return hasPermission(user, ruleEngineApproveKey(slug));
}
export function canAccessRuleEngineResource(
user: AuthUser | null | undefined,
slug: RuleEngineResourceSlug,

View File

@@ -0,0 +1,247 @@
import { useState } from "react";
import {
Badge,
Button,
Card,
Collapse,
Group,
Stack,
Text,
Textarea,
Tooltip,
} from "@mantine/core";
import type { UseMutationResult } from "@tanstack/react-query";
import { ArrowRight, CheckCircle2, Clock, XCircle } from "lucide-react";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
/** Field labels for the diff — anything not listed falls back to the raw key. */
const FIELD_LABELS: Record<string, string> = {
rateValue: "Rate",
currency: "Currency",
rateUnit: "Unit",
appliesTo: "Applies to",
trigger: "Trigger",
tradeDirection: "Direction",
containerTypeId: "Container type",
cargoTypeId: "Cargo type",
};
const fmtDateTime = (iso: string) =>
new Date(iso).toLocaleString("en-GB", {
day: "numeric",
month: "short",
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const fmtValue = (field: string, value: unknown): string => {
if (value === null || value === undefined || value === "") return "—";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
return String(value).replace(/_/g, " ");
};
/** "Ocean freight · 40HC" — what rate this change targets. */
const rateSummary = (r: RateChangeRequest): string => {
const rate = (r.rate ?? {}) as Record<string, unknown>;
const parts = [
rate.rateType ? String(rate.rateType).replace(/_/g, " ") : null,
rate.appliesTo ? String(rate.appliesTo) : null,
rate.trigger && rate.trigger !== "ALWAYS" ? String(rate.trigger) : null,
].filter(Boolean);
return parts.join(" · ") || "Rate";
};
/** The headline change, so the queue is scannable without expanding: "100 → 200 USD". */
const headline = (r: RateChangeRequest): string | null => {
if (!("rateValue" in r.payload)) return null;
const currency = String(r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record<string, unknown> | undefined)?.currency ?? "");
const before = fmtValue("rateValue", r.previousValues.rateValue);
const after = fmtValue("rateValue", r.payload.rateValue);
return `${before}${after}${currency ? ` ${currency}` : ""}`;
};
type Decide = UseMutationResult<
RateChangeRequest,
unknown,
{ id: string; decisionNote?: string }
>;
interface RateApprovalsSectionProps {
requests: RateChangeRequest[];
/** Whether this user holds the rates approve permission. */
canDecide: boolean;
approve: Decide;
reject: Decide;
}
/**
* Pending edits to LIVE rates. Each row is a before→after diff: the left value
* is what pricing charges right now and keeps charging until someone approves.
* Rendered above the rates table.
*/
const RateApprovalsSection = ({
requests,
canDecide,
approve,
reject,
}: RateApprovalsSectionProps) => {
const [openId, setOpenId] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
if (requests.length === 0) return null;
const decidingId = approve.variables?.id ?? reject.variables?.id ?? null;
return (
<Card withBorder radius="md" padding="md" mb="md">
<Group gap={8} mb={4}>
<Clock size={16} />
<Text fw={700}>Pending rate changes</Text>
<Badge variant="light" color="yellow">
{requests.length}
</Badge>
</Group>
<Text size="xs" c="dimmed" mb="sm">
Each rate below still charges its current value. Nothing changes until approved.
</Text>
<Stack gap={8}>
{requests.map((r) => {
const isOpen = openId === r.id;
const fields = Object.keys(r.payload);
const summaryLine = headline(r);
// Only the row being decided shows a spinner — the mutation's
// isPending is shared across every row.
const busy = decidingId === r.id;
return (
<Card key={r.id} withBorder radius="md" padding="sm">
<Group justify="space-between" wrap="nowrap" align="flex-start">
<Stack gap={4} style={{ minWidth: 0 }}>
<Group gap={8} wrap="nowrap">
<Badge variant="light" color="blue" radius="sm">
update
</Badge>
<Text size="sm" fw={600} truncate>
{rateSummary(r)}
</Text>
</Group>
{summaryLine ? (
<Group gap={6} wrap="nowrap">
<Text size="sm" c="dimmed" td="line-through">
{fmtValue("rateValue", r.previousValues.rateValue)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={700} c="edr-green">
{fmtValue("rateValue", r.payload.rateValue)}
</Text>
<Text size="sm" c="dimmed">
{String(
r.payload.currency ??
r.previousValues.currency ??
(r.rate as Record<string, unknown> | undefined)?.currency ??
"",
)}
</Text>
</Group>
) : null}
<Group gap={6}>
<Text size="xs" c="dimmed">
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
{fields.length === 1 ? "field" : "fields"} changed
</Text>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOpenId(isOpen ? null : r.id)}
>
{isOpen ? "Hide details" : "See all changes"}
</Button>
</Group>
</Stack>
{canDecide ? (
<Group gap={8} wrap="nowrap">
<Button
size="compact-sm"
variant="subtle"
color="red"
leftSection={<XCircle size={14} />}
loading={busy && reject.isPending}
disabled={busy && approve.isPending}
onClick={() =>
reject.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
}
>
Reject
</Button>
<Button
size="compact-sm"
color="edr-green"
leftSection={<CheckCircle2 size={14} />}
loading={busy && approve.isPending}
disabled={busy && reject.isPending}
onClick={() =>
approve.mutate({ id: r.id, decisionNote: notes[r.id] || undefined })
}
>
Approve &amp; apply
</Button>
</Group>
) : (
<Tooltip label="You need the rates approve permission to decide this">
<Badge variant="light" color="gray" radius="sm">
Awaiting approver
</Badge>
</Tooltip>
)}
</Group>
<Collapse in={isOpen}>
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
{fields.map((field) => (
<Group key={field} gap={8} wrap="nowrap">
<Text size="xs" c="dimmed" w={110} style={{ flexShrink: 0 }}>
{FIELD_LABELS[field] ?? field}
</Text>
<Text size="sm" c="dimmed" td="line-through">
{fmtValue(field, r.previousValues[field])}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field])}
</Text>
</Group>
))}
{canDecide ? (
<Textarea
mt={4}
size="xs"
autosize
minRows={2}
label="Decision note (optional)"
placeholder="Shown to the requester with your decision"
value={notes[r.id] ?? ""}
onChange={(e) =>
setNotes((prev) => ({ ...prev, [r.id]: e.currentTarget.value }))
}
/>
) : null}
</Stack>
</Collapse>
</Card>
);
})}
</Stack>
</Card>
);
};
export default RateApprovalsSection;

View File

@@ -1,5 +1,8 @@
import { useAuth } from "@/auth/useAuth";
import { canAccessRuleEngineResource } from "@/lib/permissions";
import {
canAccessRuleEngineResource,
canApproveRuleEngineChange,
} from "@/lib/permissions";
import type { ColumnDef } from "@edr/ui-common";
import {
Box,
@@ -11,14 +14,16 @@ import {
Modal,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { Plus } from "lucide-react";
import { Clock, Plus } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Navigate, useLocation, useParams } from "react-router-dom";
import { PageContainer, PageHeader } from "@/components/page";
import ManageRuleEngineOrderDialog from "@/components/ruleEngine/ManageRuleEngineOrderDialog";
import PriorityRuleApprovalsSection from "@/pages/ruleEngine/PriorityRuleApprovalsSection";
import RateApprovalsSection from "@/pages/ruleEngine/RateApprovalsSection";
import { nextPriorityRangeStart } from "@/pages/ruleEngine/priorityRuleRange";
import RuleEngineCardGrid from "@/components/ruleEngine/RuleEngineCardGrid";
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
@@ -37,6 +42,7 @@ import {
useLiveRateOptions,
useWagonTypeOptions,
usePriorityRuleWorkflow,
useRateChangeWorkflow,
useRateWorkflow,
useRuleEngineList,
useRuleEngineMutations,
@@ -51,6 +57,7 @@ import {
getRuleEngineResource,
type RuleEngineNavCategory,
} from "@/pages/ruleEngine/config/resources";
import type { RateChangeRequest } from "@/services/ruleEngine/ruleEngine.service";
import type { RuleEngineRecord } from "@/types/rule-engine";
import {
DataTable,
@@ -145,6 +152,23 @@ const RuleEngineResourcePage = () => {
chainOpen && config?.slug === "approval-rules",
);
// A LIVE rate is what pricing charges, so editing one files a change request
// instead of mutating: the rate keeps its current value until an approver
// applies the change. DRAFT rates still edit directly.
const isRates = config?.slug === "rates";
const [rateError, setRateError] = useState<string | null>(null);
const rateChangeWorkflow = useRateChangeWorkflow(
Boolean(isRates && canView),
setRateError,
);
const canApproveRates = Boolean(isRates && canApproveRuleEngineChange(user, "rates"));
/** rateId → its pending change, for the row badge. */
const pendingByRateId = useMemo(() => {
const map = new Map<string, RateChangeRequest>();
for (const r of rateChangeWorkflow.pending.data ?? []) map.set(r.rateId, r);
return map;
}, [rateChangeWorkflow.pending.data]);
// Priority rules never mutate directly: changes are filed for approval and a
// pending queue renders above the table. Validation errors (range collision,
// gap, ceiling) surface in a modal so the text is impossible to miss.
@@ -306,7 +330,27 @@ const RuleEngineResourcePage = () => {
id: col.id,
header: col.header,
meta: { headerClassName, cellClassName },
cell: ({ row }) => formatCell(row.original[col.accessorKey], col.format),
cell: ({ row }) => {
const cell = formatCell(row.original[col.accessorKey], col.format);
// On the rate column, show the proposed value under the live one — the
// live value stays the headline because it is what still gets charged.
if (!isRates || col.accessorKey !== "rateValue") return cell;
const change = pendingByRateId.get(String(row.original.id));
if (!change || change.payload.rateValue === undefined) return cell;
return (
<Stack gap={0}>
{cell}
<Tooltip label="Awaiting approval — this rate still charges its current value">
<Group gap={4} wrap="nowrap">
<Clock size={11} color="var(--mantine-color-orange-6)" />
<Text size="xs" c="orange.7" fw={600}>
{Number(change.payload.rateValue).toLocaleString()} pending
</Text>
</Group>
</Tooltip>
</Stack>
);
},
}));
base.push({
@@ -357,6 +401,8 @@ const RuleEngineResourcePage = () => {
}, [
canManage,
config,
isRates,
pendingByRateId,
submit,
handleApproveRate,
handleMoveOrder,
@@ -400,6 +446,21 @@ const RuleEngineResourcePage = () => {
currency: "USD",
trigger: isSurcharge ? values.trigger : "ALWAYS",
};
// Editing a LIVE rate files a change request — the rate keeps charging
// its current value until an approver applies it. DRAFT rates fall
// through to the normal update below.
if (editing?.id && editing.status === "LIVE") {
rateChangeWorkflow.submit.mutate(
{ rateId: String(editing.id), update: payload },
{
onSuccess: () => {
setFormOpen(false);
setEditing(null);
},
},
);
return;
}
} else if (isPriorityRules) {
// Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) };
@@ -483,6 +544,31 @@ const RuleEngineResourcePage = () => {
/>
) : null}
{isRates ? (
<RateApprovalsSection
requests={rateChangeWorkflow.pending.data ?? []}
canDecide={canApproveRates}
approve={rateChangeWorkflow.approve}
reject={rateChangeWorkflow.reject}
/>
) : null}
<Modal
opened={rateError != null}
onClose={() => setRateError(null)}
title="Cannot save rate change"
centered
>
<Text size="sm" c="red">
{rateError}
</Text>
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={() => setRateError(null)}>
Close
</Button>
</Group>
</Modal>
<Modal
opened={priorityError != null}
onClose={() => setPriorityError(null)}

View File

@@ -50,11 +50,7 @@ export default function TrainSchedulingGlobalRulesPage() {
// refilled) must not silently save as 0. Collect the numeric payload and
// reject if any value is blank or NaN.
const fields: (keyof TrainSchedulingGlobalRules)[] = [
"maxTrainLengthMeters",
"maxTrainWeightTons",
"maxWagonsPerTrain",
"max20ftContainerWeightTons",
"max20ftPairWeightDiffTons",
"importWindowLeadDays",
"exportBookingLeadHours",
"windowOpenHour",
@@ -98,32 +94,6 @@ export default function TrainSchedulingGlobalRulesPage() {
<Card maw={720}>
<Stack gap="md">
<NumberInput
label="Max train length (m)"
description="Sum of all wagon lengths must not exceed this"
value={form.maxTrainLengthMeters ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainLengthMeters: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
/>
<NumberInput
label="Max train weight (T)"
description="Total container and bulk cargo weight must not exceed this"
value={form.maxTrainWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({ ...current, maxTrainWeightTons: value }))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={1}
disabled={loading}
/>
<NumberInput
label="Max wagons per train"
value={form.maxWagonsPerTrain ?? ""}
@@ -136,38 +106,6 @@ export default function TrainSchedulingGlobalRulesPage() {
min={1}
disabled={loading}
/>
<NumberInput
label="Max 20ft container weight (T)"
description="Each individual 20ft container gross weight limit"
value={form.max20ftContainerWeightTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftContainerWeightTons: value,
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0.001}
disabled={loading}
/>
<NumberInput
label="Max 20ft pair weight difference (T)"
description="When two 20ft containers share a wagon, |weight1 weight2| must not exceed this"
value={form.max20ftPairWeightDiffTons ?? ""}
onChange={(value) =>
setForm((current) => ({
...current,
max20ftPairWeightDiffTons: value,
}))
}
clampBehavior="none"
allowNegative={false}
allowDecimal
min={0}
disabled={loading}
/>
</Stack>
</Card>

View File

@@ -48,6 +48,30 @@ export interface SubmitPriorityRuleChangePayload {
update?: Record<string, unknown>;
}
/** Approval workflow for edits to LIVE rates — a DRAFT rate still edits directly. */
const RATE_CHANGES_BASE = "/rate-change-requests";
export interface RateChangeRequest {
id: string;
rateId: string;
rate?: RuleEngineRecord | null;
/** Changed fields only. */
payload: Record<string, unknown>;
/** What those same fields were when the change was filed. */
previousValues: Record<string, unknown>;
status: "PENDING" | "APPROVED" | "REJECTED";
requestedByUserId: string | null;
decidedByUserId: string | null;
decidedAt: string | null;
decisionNote: string | null;
createdAt: string;
}
export interface SubmitRateChangePayload {
rateId: string;
update: Record<string, unknown>;
}
const RESOURCE_BASE: Record<RuleEngineResourceSlug, string> = {
"cargo-types": URL_CONSTANTS.RULE_ENGINE.CARGO_TYPES,
"container-types": URL_CONSTANTS.RULE_ENGINE.CONTAINER_TYPES,
@@ -306,6 +330,44 @@ export const ruleEngineService = {
return unwrap(response.data) as PriorityRuleChangeRequest;
},
/** Propose a change to a LIVE rate — it stays at its current value until approved. */
submitRateChange: async (
payload: SubmitRateChangePayload,
): Promise<RateChangeRequest> => {
const response = await client.post(RATE_CHANGES_BASE, payload);
return unwrap(response.data) as RateChangeRequest;
},
listRateChanges: async (
status?: RateChangeRequest["status"],
): Promise<RateChangeRequest[]> => {
const response = await client.get(RATE_CHANGES_BASE, {
params: status ? { status } : undefined,
});
const body = unwrap(response.data) as unknown;
return Array.isArray(body) ? (body as RateChangeRequest[]) : [];
},
approveRateChange: async (
id: string,
decisionNote?: string,
): Promise<RateChangeRequest> => {
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/approve`, {
decisionNote,
});
return unwrap(response.data) as RateChangeRequest;
},
rejectRateChange: async (
id: string,
decisionNote?: string,
): Promise<RateChangeRequest> => {
const response = await client.post(`${RATE_CHANGES_BASE}/${id}/reject`, {
decisionNote,
});
return unwrap(response.data) as RateChangeRequest;
},
getApprovalChain: async (
requiresDirectorApproval = true,
): Promise<RuleEngineRecord[]> => {

View File

@@ -112,11 +112,7 @@ export interface DeferredBookingRow {
export interface TrainSchedulingGlobalRules {
id: string;
maxTrainLengthMeters: number;
maxTrainWeightTons: number;
maxWagonsPerTrain: number;
max20ftContainerWeightTons: number;
max20ftPairWeightDiffTons: number;
importWindowLeadDays: number;
exportBookingLeadHours: number;
windowOpenHour: number;