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 = { 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 => { // "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; 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, ): Array<{ field: string; label: string; before: string; after: string; suffix: string }> => { const currency = String( r.payload.currency ?? r.previousValues.currency ?? (r.rate as Record | 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; } /** * 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(null); const [notes, setNotes] = useState>({}); if (requests.length === 0) return null; const decidingId = approve.variables?.id ?? reject.variables?.id ?? null; return ( Pending rate changes {requests.length} Each rate below still charges its current value. Nothing changes until approved. {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 ( update {rateSummary(r)} {rows.map((row) => ( {row.label} {row.before} {row.after} {row.suffix} ))} Submitted {fmtDateTime(r.createdAt)} · {fields.length}{" "} {fields.length === 1 ? "field" : "fields"} changed {canDecide ? ( ) : null} {canDecide ? ( ) : ( Awaiting approver )} {canDecide ? ( {/* The change itself is always visible above, so this panel carries only what the approver adds. */}