Files
edr-platform/apps/edr-freight-web/backoffice/src/pages/ruleEngine/RateApprovalsSection.tsx
2026-07-23 13:54:09 +00:00

261 lines
9.3 KiB
TypeScript

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",
originYardId: "Origin yard",
destinationYardId: "Destination yard",
};
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,
labels?: Record<string, string>,
): string => {
if (value === null || value === undefined || value === "") return "—";
if (field === "rateValue") {
const num = Number(value);
return Number.isNaN(num) ? String(value) : num.toLocaleString();
}
// Yard ids are unreadable — an approver decides on the route, not a UUID.
if (field === "originYardId" || field === "destinationYardId") {
return labels?.[String(value)] ?? String(value);
}
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;
/** yardId → label, so a re-routed rate reads as yards, not UUIDs. */
yardLabels?: Record<string, string>;
}
/**
* 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,
yardLabels,
}: 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], yardLabels)}
</Text>
<ArrowRight size={13} />
<Text size="sm" fw={600}>
{fmtValue(field, r.payload[field], yardLabels)}
</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;