mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-05 17:43:39 +00:00
286 lines
9.8 KiB
TypeScript
286 lines
9.8 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",
|
|
minKm: "From km",
|
|
maxKm: "To km",
|
|
baseLiters: "Base liters",
|
|
rateType: "Rate type",
|
|
};
|
|
|
|
/**
|
|
* A key the backend diffed but the UI has no label for still names a real
|
|
* change, so turn "baseLiters" into "Base liters" rather than hiding it.
|
|
*/
|
|
const labelFor = (field: string): string =>
|
|
FIELD_LABELS[field] ??
|
|
field
|
|
.replace(/([A-Z])/g, " $1")
|
|
.replace(/^./, (c) => c.toUpperCase())
|
|
.replace(/\bId\b/, "")
|
|
.trim();
|
|
|
|
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 => {
|
|
// "Not set" reads as a real before-state; a bare em dash on both sides of the
|
|
// arrow made a newly-set field look like no change at all.
|
|
if (value === null || value === undefined || value === "") return "Not set";
|
|
if (field === "rateValue") {
|
|
const num = Number(value);
|
|
return Number.isNaN(num) ? String(value) : num.toLocaleString();
|
|
}
|
|
// Any id is unreadable — an approver decides on "Perishable → Truck", not on
|
|
// a pair of uuids. Covers yards, cargo types, container types and lines.
|
|
if (field.endsWith("Id")) {
|
|
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";
|
|
};
|
|
|
|
/**
|
|
* Every change in the request, as readable before→after pairs. The queue must
|
|
* be scannable without expanding: a cargo or direction change is just as much
|
|
* the point as a repricing, so it gets the same one-line treatment as the rate.
|
|
*/
|
|
const summaryRows = (
|
|
r: RateChangeRequest,
|
|
labels?: Record<string, string>,
|
|
): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => {
|
|
const currency = String(
|
|
r.payload.currency ??
|
|
r.previousValues.currency ??
|
|
(r.rate as Record<string, unknown> | undefined)?.currency ??
|
|
"",
|
|
);
|
|
// Rate first — it is what most changes are about — then the rest in a stable
|
|
// order so the same edit always reads the same way.
|
|
const fields = Object.keys(r.payload).sort((a, b) =>
|
|
a === "rateValue" ? -1 : b === "rateValue" ? 1 : a.localeCompare(b),
|
|
);
|
|
return fields.map((field) => ({
|
|
field,
|
|
label: labelFor(field),
|
|
before: fmtValue(field, r.previousValues[field], labels),
|
|
after: fmtValue(field, r.payload[field], labels),
|
|
suffix: field === "rateValue" && 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;
|
|
/** id → label for every reference a diff can name (yards, cargo/container
|
|
* types, shipping lines), so a change reads as names, not UUIDs. */
|
|
refLabels?: 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,
|
|
refLabels,
|
|
}: 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 rows = summaryRows(r, refLabels);
|
|
// 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>
|
|
|
|
{rows.map((row) => (
|
|
<Group key={row.field} gap={6} wrap="wrap" align="center">
|
|
<Text size="xs" c="dimmed">
|
|
{row.label}
|
|
</Text>
|
|
<Text size="sm" c="dimmed" td="line-through">
|
|
{row.before}
|
|
</Text>
|
|
<ArrowRight size={13} style={{ flexShrink: 0 }} />
|
|
<Text size="sm" fw={700} c="edr-green">
|
|
{row.after}
|
|
{row.suffix}
|
|
</Text>
|
|
</Group>
|
|
))}
|
|
|
|
<Group gap={6}>
|
|
<Text size="xs" c="dimmed">
|
|
Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "}
|
|
{fields.length === 1 ? "field" : "fields"} changed
|
|
</Text>
|
|
{canDecide ? (
|
|
<Button
|
|
size="compact-xs"
|
|
variant="subtle"
|
|
onClick={() => setOpenId(isOpen ? null : r.id)}
|
|
>
|
|
{isOpen ? "Hide note" : "Add a note"}
|
|
</Button>
|
|
) : null}
|
|
</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 & 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}>
|
|
{canDecide ? (
|
|
<Stack gap={6} mt="sm" pt="sm" style={{ borderTop: "1px solid var(--mantine-color-default-border)" }}>
|
|
{/* The change itself is always visible above, so this panel
|
|
carries only what the approver adds. */}
|
|
<Textarea
|
|
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 }))
|
|
}
|
|
/>
|
|
</Stack>
|
|
) : null}
|
|
</Collapse>
|
|
</Card>
|
|
);
|
|
})}
|
|
</Stack>
|
|
</Card>
|
|
);
|
|
};
|
|
|
|
export default RateApprovalsSection;
|