// Risk Rating + Periodic Review views.
//
// RiskRatingsView — current customer risk ratings, distribution, factor breakdown,
// full customer table sortable/filterable by tier.
// PeriodicReviewView— review cadence & migration: KPIs, migration matrix,
// "deserves attention" list, recent reviews log.
const { useState: useRRState, useMemo: useRRMemo } = React;
// ── Shared bits ─────────────────────────────────────────────────────────────
function RatingPill({ rating, size = "md" }) {
// map rating → color tokens
const map = {
severe: { bg: "var(--risk-high-bg)", fg: "var(--risk-high)", label: "Severe" },
high: { bg: "oklch(95% 0.05 35)", fg: "oklch(56% 0.20 35)",label: "High" },
med: { bg: "var(--risk-med-bg)", fg: "var(--risk-med)", label: "Medium" },
low: { bg: "var(--risk-low-bg)", fg: "var(--risk-low)", label: "Low" },
};
// Dark-mode override for the "high" non-token color so it lifts properly
const isDark = document.documentElement.dataset.theme === "dark";
if (isDark && rating === "high") {
map.high = { bg: "oklch(28% 0.08 35)", fg: "oklch(76% 0.18 35)", label: "High" };
}
const m = map[rating];
const cls = size === "sm" ? "" : "";
return (
{m.label}
);
}
function ScoreBadge({ score }) {
const c = score >= 80 ? "var(--risk-high)"
: score >= 60 ? "oklch(56% 0.20 35)"
: score >= 35 ? "var(--risk-med)"
: "var(--risk-low)";
const isDark = document.documentElement.dataset.theme === "dark";
const cLifted = isDark && score >= 60 && score < 80 ? "oklch(76% 0.18 35)" : c;
return (
{score}
);
}
function MiniTrend({ data, height = 22, width = 70 }) {
const min = Math.min(...data), max = Math.max(...data);
const range = max - min || 1;
const last = data[data.length - 1];
const prev = data[0];
const dir = last > prev ? "up" : last < prev ? "down" : "flat";
const c = dir === "up" ? "var(--risk-high)"
: dir === "down" ? "var(--risk-low)"
: "var(--fg-3)";
const pts = data.map((v, i) => {
const x = (i / (data.length - 1)) * (width - 2) + 1;
const y = height - 2 - ((v - min) / range) * (height - 4);
return `${x.toFixed(1)},${y.toFixed(1)}`;
});
return (
);
}
function StackedDistBar() {
// Single horizontal bar split by tier
const order = ["low", "med", "high", "severe"];
const total = order.reduce((s, k) => s + RATING_DIST[k].count, 0);
return (
{FACTOR_AVG.map(f => (
{f.label}
= 60 ? "var(--risk-high)"
: f.value >= 35 ? "var(--risk-med)"
: "var(--risk-low)",
borderRadius: 3,
}} />
{f.value}
))}
);
}
function RatingTrendChart() {
// Stacked area chart, 12 months × 4 tiers
const W = 720, H = 180, P = { l: 36, r: 8, t: 8, b: 22 };
const innerW = W - P.l - P.r;
const innerH = H - P.t - P.b;
const data = RATING_TREND;
const total = (m) => m.low + m.med + m.high + m.severe;
const maxTotal = Math.max(...data.map(total));
// Y for each tier stack (cumulative from low at bottom up to severe at top)
const colors = {
low: "var(--risk-low)",
med: "var(--risk-med)",
high: "oklch(56% 0.20 35)",
severe: "var(--risk-high)",
};
const isDark = document.documentElement.dataset.theme === "dark";
if (isDark) colors.high = "oklch(76% 0.18 35)";
const buildArea = (key, prevKey) => {
const upper = data.map((d, i) => {
const x = P.l + (i / (data.length - 1)) * innerW;
const y = P.t + innerH - (cum(d, key) / maxTotal) * innerH;
return [x, y];
});
const lower = data.slice().reverse().map((d, i) => {
const realI = data.length - 1 - i;
const x = P.l + (realI / (data.length - 1)) * innerW;
const y = P.t + innerH - (cum(d, prevKey) / maxTotal) * innerH;
return [x, y];
});
return upper.concat(lower)
.map((p, i) => (i === 0 ? "M" : "L") + p[0].toFixed(1) + " " + p[1].toFixed(1))
.join(" ") + " Z";
};
const cum = (d, key) => {
if (key === "low") return d.low;
if (key === "med") return d.low + d.med;
if (key === "high") return d.low + d.med + d.high;
if (key === "severe") return d.low + d.med + d.high + d.severe;
return 0;
};
// Y axis grid lines
const yTicks = [0, Math.round(maxTotal / 2), maxTotal];
return (
);
}
// ── Risk Ratings view ───────────────────────────────────────────────────────
function mapRiskProfileToCustomer(p) {
const score = Math.round(Number(p.risk_score || 0));
let rating = "low";
if (score >= 80) rating = "severe";
else if (score >= 60) rating = "high";
else if (score >= 35) rating = "med";
return {
id: p.entity_id,
name: p.entity_id,
type: p.entity_type === "business" ? "business" : "person",
country: "—",
segment: "Live · " + p.entity_type,
onboarded: "—",
rating,
score,
prior: "low",
priorScore: Math.max(0, score - 10),
lastReview: p.last_transaction_at ? p.last_transaction_at.slice(0, 10) : "—",
nextReview: "—",
overdue: false,
factors: { geo: 30, type: 40, product: 50, behavior: score },
trend: [Math.max(0, score - 20), Math.max(0, score - 15), Math.max(0, score - 10), Math.max(0, score - 5), score],
flagged: score >= 60,
reviewer: "System",
reasons: p.risk_factors || ["Live risk profile"],
_raw: p,
};
}
function RiskRatingsView({ onOpenCustomer, data }) {
const liveProfiles = (data?.riskProfiles || []).map(mapRiskProfileToCustomer);
const customers = liveProfiles.length ? liveProfiles : CUSTOMERS;
const [tier, setTier] = useRRState("all");
const [sortBy, setSortBy] = useRRState("score");
const counts = useRRMemo(() => ({
all: customers.length,
severe: customers.filter(c => c.rating === "severe").length,
high: customers.filter(c => c.rating === "high").length,
med: customers.filter(c => c.rating === "med").length,
low: customers.filter(c => c.rating === "low").length,
flagged:customers.filter(c => c.flagged).length,
}), [customers]);
const filtered = useRRMemo(() => {
let r = customers;
if (tier === "severe") r = r.filter(c => c.rating === "severe");
else if (tier === "high") r = r.filter(c => c.rating === "high");
else if (tier === "med") r = r.filter(c => c.rating === "med");
else if (tier === "low") r = r.filter(c => c.rating === "low");
else if (tier === "flagged") r = r.filter(c => c.flagged);
r = [...r].sort((a, b) =>
sortBy === "score" ? b.score - a.score
: sortBy === "delta" ? (b.score - b.priorScore) - (a.score - a.priorScore)
: sortBy === "name" ? a.name.localeCompare(b.name)
: a.lastReview < b.lastReview ? 1 : -1);
return r;
}, [tier, sortBy, customers]);
return (
<>
Population distribution
1,288 rated customers
Avg factor contribution
population mean
{[
{ k: "severe", l: "Severe", v: RR_KPIS.severe, c: "var(--risk-high)" },
{ k: "newHigh", l: "New high · 30d", v: RR_KPIS.newHigh, c: "oklch(56% 0.20 35)" },
{ k: "rerated", l: "Re-rated · MTD", v: RR_KPIS.reratedMTD, c: "var(--fg)" },
{ k: "pending", l: "Pending refresh", v: RR_KPIS.pendingRefresh, c: "var(--risk-med)" },
].map(({ k, l, v, c }) => (
{l}
{v.value}
{v.delta} this month
))}
Customer ratings
| Customer ID |
Name · Segment |
Tenant |
Rating |
Score |
Δ vs prior |
Factor profile |
Trend · 8mo |
Last review |
Next review |
{filtered.map(c => {
const delta = c.score - c.priorScore;
const deltaUp = delta > 0;
const tierChanged = c.rating !== c.prior;
return (
onOpenCustomer?.(c)}>
|
{c.id}
{c.country}
|
{c.type === "business" ? : }
|
|
{tierChanged && (
from {c.prior === "med" ? "med" : c.prior}
)}
|
/100
|
{deltaUp && }
{delta < 0 && }
{delta > 0 ? "+" : ""}{delta}
|
|
|
{c.lastReview} |
{c.overdue && }
{c.nextReview}
{c.overdue && (
overdue
)}
|
);
})}
>
);
}
function FactorRow({ factors }) {
const keys = [
{ k: "geo", label: "Geo" },
{ k: "type", label: "Type" },
{ k: "product", label: "Prod" },
{ k: "behavior", label: "Beh" },
];
return (
{keys.map(({ k, label }) => {
const v = factors[k];
const c = v >= 80 ? "var(--risk-high)"
: v >= 60 ? "oklch(56% 0.20 35)"
: v >= 35 ? "var(--risk-med)"
: "var(--risk-low)";
return (
);
})}
);
}
// ── Periodic Review view ────────────────────────────────────────────────────
const TIER_IDX = { low: 0, med: 1, high: 2, severe: 3 };
function ChangeArrow({ prior, next, scoreDelta }) {
const dx = TIER_IDX[next] - TIER_IDX[prior];
const upgraded = dx > 0; // upgraded = worse (higher tier)
const downgraded = dx < 0;
const c = upgraded ? "var(--risk-high)" : downgraded ? "var(--risk-low)" : "var(--fg-3)";
return (
{scoreDelta != null && (
{scoreDelta > 0 ? "+" : ""}{scoreDelta}
)}
);
}
function MigrationMatrix() {
const rows = MIGRATION.rows;
const cells = MIGRATION.cells;
const max = Math.max(...cells.flat());
const isDark = document.documentElement.dataset.theme === "dark";
// colour intensity:
// cell on diagonal → neutral grey
// above diagonal (upgraded to worse) → red intensity
// below diagonal (downgraded to better) → green intensity
const cellBg = (r, c, v) => {
if (v === 0) return "var(--bg-2)";
const t = v / max;
if (isDark) {
if (r === c) return `oklch(${18 + t * 12}% 0.008 260)`;
if (c > r) return `oklch(${22 + t * 30}% ${0.04 + t * 0.16} 22)`;
return `oklch(${22 + t * 20}% ${0.04 + t * 0.10} 155)`;
}
if (r === c) return `oklch(${94 - t * 18}% 0.005 260)`;
if (c > r) return `oklch(${94 - t * 36}% ${0.03 + t * 0.14} 22)`;
return `oklch(${94 - t * 24}% ${0.02 + t * 0.10} 155)`;
};
const cellFg = (r, c, v) => {
if (v === 0) return "var(--fg-4)";
if (isDark) {
// Dark mode — text always light enough to read on the tinted bg
if (v / max < 0.15) return "var(--fg-2)";
return "var(--fg)";
}
if (c > r && v / max > 0.4) return "#fff";
if (r === c && v / max > 0.6) return "#fff";
return "var(--fg)";
};
const tierLabel = (t) => t === "med" ? "Medium" : t[0].toUpperCase() + t.slice(1);
return (
{rows.map(r => (
{tierLabel(r)}
))}
{rows.map((r, ri) => (
{tierLabel(r)}
{rows.map((c, ci) => {
const v = cells[ri][ci];
const attention = ci > ri && (ci - ri) >= 2; // big jump upwards
return (
0 ? "1px solid var(--risk-high)" : "1px solid transparent",
}}>
{v}
{ci === ri && v > 0 && (
no change
)}
);
})}
))}
Rows: prior rating
Columns: new rating · last 30 days
upgraded · worse
downgraded · better
needs attention
);
}
function AttentionList() {
// Filter REVIEWS where tier change is big OR is into high/severe
const items = REVIEWS.filter(r => {
const dx = TIER_IDX[r.new] - TIER_IDX[r.prior];
return dx >= 1 && (r.new === "severe" || dx >= 2 || (r.new === "high" && r.prior !== "high"));
});
return (
{items.map((r, i) => {
const dx = TIER_IDX[r.new] - TIER_IDX[r.prior];
const big = dx >= 2 || r.new === "severe";
return (
{r.customer}
{big &&
Attention
}
{r.id} · {r.reason}
{r.completed}
by {r.reviewer}
);
})}
);
}
function PeriodicReviewView() {
const [tab, setTab] = useRRState("recent");
return (
<>
{/* KPI strip */}
{[
{ ...PR_KPIS.dueMonth, color: "var(--accent)" },
{ ...PR_KPIS.overdue, color: "var(--risk-high)" },
{ ...PR_KPIS.completed, color: "var(--risk-low)" },
{ ...PR_KPIS.ratingChanges, color: "var(--fg)" },
{ ...PR_KPIS.escalations, color: "var(--risk-high)" },
{ ...PR_KPIS.avgCycle, color: "var(--fg)" },
].map((k, i) => (
{k.label}
{k.value}
{k.delta} vs last month
))}
Rating migration · last 30 days
456 transitions
Deserves attention
6 customers
Population over 12 months · by rating tier
stacked count
Recent reviews
{REVIEWS.length} reviews · last 5 days
| Review |
Customer |
Tenant |
Rating change |
Reason |
Reviewer |
Completed |
{REVIEWS.map(r => (
| {r.id} |
{r.customer} |
|
|
{r.reason} |
{r.reviewer.split(" ").map(x => x[0]).join("").slice(0,2)}
{r.reviewer.split(" ")[0]}
|
{r.completed} |
))}
>
);
}
Object.assign(window, { RiskRatingsView, PeriodicReviewView });