@definite-app/data-apps 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CLAUDE.md +686 -0
  2. package/LICENSE +201 -0
  3. package/README.md +643 -0
  4. package/build.mjs +459 -0
  5. package/examples/_refined_demo/app.json +15 -0
  6. package/examples/_refined_demo/data/sample.parquet +0 -0
  7. package/examples/_refined_demo/gen_preview_data.py +59 -0
  8. package/examples/_refined_demo/preview-data.json +13 -0
  9. package/examples/_refined_demo/src/App.tsx +188 -0
  10. package/examples/_refined_demo/src/main.tsx +12 -0
  11. package/examples/loan-portfolio/app.json +31 -0
  12. package/examples/loan-portfolio/data/loan_book.parquet +0 -0
  13. package/examples/loan-portfolio/gen_preview_data.py +454 -0
  14. package/examples/loan-portfolio/preview-data.json +84 -0
  15. package/examples/loan-portfolio/src/App.tsx +1103 -0
  16. package/examples/loan-portfolio/src/main.tsx +12 -0
  17. package/examples/revenue-explorer/app.json +23 -0
  18. package/examples/revenue-explorer/data/transactions.parquet +0 -0
  19. package/examples/revenue-explorer/gen_preview_data.py +129 -0
  20. package/examples/revenue-explorer/preview-data.json +49 -0
  21. package/examples/revenue-explorer/src/App.tsx +527 -0
  22. package/examples/revenue-explorer/src/main.tsx +12 -0
  23. package/package.json +55 -0
  24. package/preview.mjs +35 -0
  25. package/runtime/definite-runtime.tsx +5934 -0
  26. package/scripts/headless-smoke.mjs +196 -0
  27. package/templates/blank/app.json +15 -0
  28. package/templates/blank/src/App.tsx +41 -0
  29. package/templates/blank/src/main.tsx +12 -0
  30. package/templates/refined/app.json +15 -0
  31. package/templates/refined/src/App.tsx +198 -0
  32. package/templates/refined/src/main.tsx +12 -0
@@ -0,0 +1,1103 @@
1
+ import React, { useMemo, useState } from "react";
2
+
3
+ import {
4
+ Breadcrumb,
5
+ buildDrillPrompt,
6
+ buildPalette,
7
+ CachePopover,
8
+ callFiFast,
9
+ DateRangeFilter,
10
+ DEFAULT_DATE_RANGE_PRESETS,
11
+ type DateRangeValue,
12
+ type DrillAiChatConfig,
13
+ DrillProvider,
14
+ ErrorState,
15
+ type FilterAccordionGroup,
16
+ type FilterAccordionOption,
17
+ LoadingState,
18
+ PaletteProvider,
19
+ SaasDataTable,
20
+ type SaasDataTableColumn,
21
+ SaasKpiCard,
22
+ ShellLayout,
23
+ Sidebar,
24
+ type SidebarNavItem,
25
+ SkeletonShimmer,
26
+ Sparkline,
27
+ useDataset,
28
+ useDrill,
29
+ useJsonResource,
30
+ usePalette,
31
+ useSqlQuery,
32
+ useTheme,
33
+ } from "@definite/runtime";
34
+
35
+ // Wire the drill drawer's optional follow-up chat to Definite's /v4/fi-fast
36
+ // endpoint. Leave null to hide the chat input entirely (default). When the
37
+ // app runs inside the Definite iframe the same-origin cookie gets sent; for
38
+ // local preview builds the fetch will just 401 and show a friendly error.
39
+ const AI_CHAT: DrillAiChatConfig | undefined = {
40
+ onAsk: async (userMessage, entity) => {
41
+ return callFiFast({
42
+ prompt: buildDrillPrompt(userMessage, entity),
43
+ // endpoint defaults to "/v4/fi-fast"; override for non-prod origins.
44
+ });
45
+ },
46
+ placeholder: "Ask a follow-up about this…",
47
+ disclaimer: "Answers generated by Gemini Flash via Definite's fi-fast endpoint.",
48
+ };
49
+
50
+ // ── SQL helpers ───────────────────────────────────────────────────────────
51
+ const esc = (v: string) => v.replace(/'/g, "''");
52
+
53
+ type Filters = Record<string, string[]>;
54
+
55
+ function buildWhere(filters: Filters, from: string, to: string): string {
56
+ const clauses: string[] = [];
57
+ if (from) clauses.push(`originated >= '${esc(from)}'`);
58
+ if (to) clauses.push(`originated <= '${esc(to)}'`);
59
+ for (const [col, vals] of Object.entries(filters)) {
60
+ if (!vals || vals.length === 0) continue;
61
+ const inList = vals.map((v) => `'${esc(v)}'`).join(",");
62
+ clauses.push(`${col} IN (${inList})`);
63
+ }
64
+ return clauses.length > 0 ? ` WHERE ${clauses.join(" AND ")}` : "";
65
+ }
66
+
67
+ // Formatters
68
+ const fmtMoney = (v: unknown) => {
69
+ const n = Number(v);
70
+ if (!Number.isFinite(n)) return "—";
71
+ if (Math.abs(n) >= 1e6) return `$${(n / 1e6).toFixed(1)}M`;
72
+ if (Math.abs(n) >= 1e3) return `$${Math.round(n / 1e3)}K`;
73
+ return `$${Math.round(n).toLocaleString()}`;
74
+ };
75
+ const fmtMoneyFull = (v: unknown) => {
76
+ const n = Number(v);
77
+ if (!Number.isFinite(n)) return "—";
78
+ return `$${Math.round(n).toLocaleString()}`;
79
+ };
80
+ const fmtNum = (v: unknown) => {
81
+ const n = Number(v);
82
+ if (!Number.isFinite(n)) return "—";
83
+ return n.toLocaleString();
84
+ };
85
+ const fmtPct = (v: unknown, d = 2) => {
86
+ const n = Number(v);
87
+ if (!Number.isFinite(n)) return "—";
88
+ return `${n.toFixed(d)}%`;
89
+ };
90
+
91
+ // Loan-specific swatch + humanize functions (app-level concern, stays here).
92
+ const FICO_SWATCH: Record<string, string> = {
93
+ A: "#10b981", B: "#84cc16", C: "#f59e0b", D: "#f97316", E: "#ef4444",
94
+ };
95
+ const STATUS_SWATCH: Record<string, string> = {
96
+ current: "#10b981", late_30: "#f59e0b", late_60: "#f59e0b",
97
+ late_90: "#ef4444", paid_off: "#71717a", charged_off: "#ef4444",
98
+ };
99
+ const INCOME_LABELS: Record<string, string> = {
100
+ lt50: "< $50K", "50_75": "$50K–$75K", "75_100": "$75K–$100K",
101
+ "100_150": "$100K–$150K", "150_200": "$150K–$200K", gt200: "$200K+",
102
+ };
103
+ const DTI_LABELS: Record<string, string> = {
104
+ lt20: "Under 20%", "20_30": "20–30%", "30_40": "30–40%", gt40: "40%+",
105
+ };
106
+
107
+ type FilterGroupMeta = {
108
+ id: string;
109
+ label: string;
110
+ format?: (v: string) => string;
111
+ };
112
+ const FILTER_GROUPS_META: FilterGroupMeta[] = [
113
+ { id: "ficoBand", label: "Risk band" },
114
+ { id: "status", label: "Loan status", format: (v) => v.replace(/_/g, " ") },
115
+ { id: "state", label: "State" },
116
+ { id: "vintage", label: "Origination vintage" },
117
+ { id: "product", label: "Product type", format: (v) => v.replace(/_/g, " ") },
118
+ { id: "term", label: "Loan term", format: (v) => `${v} months` },
119
+ { id: "channel", label: "Acquisition channel", format: (v) => v.replace(/_/g, " ") },
120
+ { id: "employment", label: "Employment", format: (v) => v.replace(/_/g, " ") },
121
+ { id: "incomeBand", label: "Income band" },
122
+ { id: "dtiBand", label: "DTI ratio" },
123
+ { id: "collectionsFlag", label: "Collections flag" },
124
+ { id: "payMethod", label: "Payment method", format: (v) => v.replace(/_/g, " ") },
125
+ ];
126
+ const META_BY_ID = Object.fromEntries(FILTER_GROUPS_META.map((g) => [g.id, g]));
127
+
128
+ function humanize(groupId: string, value: string): string {
129
+ if (groupId === "incomeBand") return INCOME_LABELS[value] ?? value;
130
+ if (groupId === "dtiBand") return DTI_LABELS[value] ?? value;
131
+ if (groupId === "ficoBand") return `Band ${value}`;
132
+ const meta = META_BY_ID[groupId];
133
+ return meta?.format ? meta.format(value) : value;
134
+ }
135
+ function swatchFor(groupId: string, value: string): string | undefined {
136
+ if (groupId === "ficoBand") return FICO_SWATCH[value];
137
+ if (groupId === "status") return STATUS_SWATCH[value];
138
+ return undefined;
139
+ }
140
+
141
+ function initialDateRange(): DateRangeValue {
142
+ const preset =
143
+ DEFAULT_DATE_RANGE_PRESETS.find((p) => p.key === "last12m") ??
144
+ DEFAULT_DATE_RANGE_PRESETS[0];
145
+ return preset.compute();
146
+ }
147
+
148
+ // Data shapes returned by useSqlQuery
149
+ type KpiRow = {
150
+ totalOutstanding: number;
151
+ activeCount: number;
152
+ avgPrincipal: number;
153
+ delinquent90Count: number;
154
+ totalCount: number;
155
+ };
156
+ type StatusRow = { status: string; cnt: number; vol: number };
157
+ type MonthRow = { originatedMonth: string; vol: number; cnt: number };
158
+ type BandRow = { ficoBand: string; cnt: number; vol: number; avgRate: number };
159
+ type StateRow = { state: string; cnt: number; vol: number };
160
+ type RecentRow = { loanId: string; borrower: string; amount: number; fico: number; status: string; state: string; originated: string };
161
+ type DimRow = { id: string; cnt: number };
162
+
163
+ const NAV_ITEMS: SidebarNavItem[] = [
164
+ { id: "overview", label: "Overview", icon: "◧" },
165
+ { id: "loans", label: "Loans", icon: "≣" },
166
+ { id: "risk", label: "Risk", icon: "◎" },
167
+ { id: "originations", label: "Originations", icon: "↗" },
168
+ { id: "delinquency", label: "Delinquency", icon: "⚠" },
169
+ { id: "geography", label: "Geography", icon: "◔" },
170
+ ];
171
+
172
+ function statusTone(status: string, ok: string, warn: string, bad: string, dim: string) {
173
+ if (status === "current") return ok;
174
+ if (status === "paid_off") return dim;
175
+ if (status === "late_30" || status === "late_60") return warn;
176
+ return bad;
177
+ }
178
+ function statusSoft(status: string, okSoft: string, warnSoft: string, badSoft: string, elev: string) {
179
+ if (status === "current") return okSoft;
180
+ if (status === "paid_off") return elev;
181
+ if (status === "late_30" || status === "late_60") return warnSoft;
182
+ return badSoft;
183
+ }
184
+
185
+ // ── Top-level App ─────────────────────────────────────────────────────────
186
+
187
+ export default function App() {
188
+ const { theme, toggleTheme } = useTheme();
189
+ const palette = useMemo(() => buildPalette(theme), [theme]);
190
+
191
+ const data = useDataset("loans");
192
+ if (data.loading) return <LoadingState message="Loading loan book…" />;
193
+ if (data.error) return <ErrorState title="Dataset failed to load" message={data.error} />;
194
+
195
+ return (
196
+ <PaletteProvider value={palette}>
197
+ <DrillProvider aiChat={AI_CHAT}>
198
+ <InnerApp
199
+ theme={theme}
200
+ onThemeChange={(t) => { if (t !== theme) toggleTheme(); }}
201
+ dataset={data}
202
+ />
203
+ </DrillProvider>
204
+ </PaletteProvider>
205
+ );
206
+ }
207
+
208
+ type DatasetHandle = ReturnType<typeof useDataset>;
209
+
210
+ function InnerApp({ theme, onThemeChange, dataset }: {
211
+ theme: "dark" | "light";
212
+ onThemeChange: (t: "dark" | "light") => void;
213
+ dataset: DatasetHandle;
214
+ }) {
215
+ const P = usePalette();
216
+ const riskBands = useJsonResource<Array<{ band: string; range: string; apr: number; defaultRate: number; color: string }>>("riskBands");
217
+
218
+ const [view, setView] = useState("overview");
219
+ const [dateRange, setDateRange] = useState<DateRangeValue>(initialDateRange);
220
+ const [filters, setFilters] = useState<Filters>({});
221
+
222
+ const where = useMemo(() => buildWhere(filters, dateRange.from, dateRange.to), [filters, dateRange.from, dateRange.to]);
223
+ const t = dataset.tableRef;
224
+
225
+ const dimCounts = useSqlQuery<DimRow[]>(
226
+ dataset,
227
+ t ? `
228
+ WITH base AS (SELECT * FROM ${t})
229
+ SELECT 'ficoBand||' || ficoBand AS id, COUNT(*)::INTEGER AS cnt FROM base GROUP BY 1
230
+ UNION ALL SELECT 'status||' || status, COUNT(*)::INTEGER FROM base GROUP BY 1
231
+ UNION ALL SELECT 'state||' || state, COUNT(*)::INTEGER FROM base GROUP BY 1
232
+ UNION ALL SELECT 'vintage||' || vintage, COUNT(*)::INTEGER FROM base GROUP BY 1
233
+ UNION ALL SELECT 'product||' || product, COUNT(*)::INTEGER FROM base GROUP BY 1
234
+ UNION ALL SELECT 'term||' || term::VARCHAR, COUNT(*)::INTEGER FROM base GROUP BY 1
235
+ UNION ALL SELECT 'channel||' || channel, COUNT(*)::INTEGER FROM base GROUP BY 1
236
+ UNION ALL SELECT 'employment||' || employment, COUNT(*)::INTEGER FROM base GROUP BY 1
237
+ UNION ALL SELECT 'incomeBand||' || incomeBand, COUNT(*)::INTEGER FROM base GROUP BY 1
238
+ UNION ALL SELECT 'dtiBand||' || dtiBand, COUNT(*)::INTEGER FROM base GROUP BY 1
239
+ UNION ALL SELECT 'collectionsFlag||' || collectionsFlag, COUNT(*)::INTEGER FROM base GROUP BY 1
240
+ UNION ALL SELECT 'payMethod||' || payMethod, COUNT(*)::INTEGER FROM base GROUP BY 1
241
+ ` : "",
242
+ [],
243
+ );
244
+
245
+ const filterGroups: FilterAccordionGroup[] = useMemo(() => {
246
+ const rows = dimCounts.data || [];
247
+ const byGroup: Record<string, FilterAccordionOption[]> = {};
248
+ for (const r of rows) {
249
+ const [gid, val] = String(r.id).split("||");
250
+ if (!gid || !val) continue;
251
+ (byGroup[gid] ||= []).push({
252
+ id: val,
253
+ label: humanize(gid, val),
254
+ hint: (r.cnt as number).toLocaleString(),
255
+ swatch: swatchFor(gid, val),
256
+ });
257
+ }
258
+ const orderOf = {
259
+ status: ["current", "late_30", "late_60", "late_90", "paid_off", "charged_off"],
260
+ incomeBand: ["lt50", "50_75", "75_100", "100_150", "150_200", "gt200"],
261
+ dtiBand: ["lt20", "20_30", "30_40", "gt40"],
262
+ } as Record<string, string[]>;
263
+ return FILTER_GROUPS_META.map((g) => ({
264
+ id: g.id,
265
+ label: g.label,
266
+ options: (byGroup[g.id] || []).slice().sort((a, b) => {
267
+ if (g.id === "ficoBand") return a.id.localeCompare(b.id);
268
+ if (orderOf[g.id]) return orderOf[g.id].indexOf(a.id) - orderOf[g.id].indexOf(b.id);
269
+ if (g.id === "term") return Number(a.id) - Number(b.id);
270
+ return Number((b.hint ?? "0").replace(/,/g, "")) - Number((a.hint ?? "0").replace(/,/g, ""));
271
+ }),
272
+ }));
273
+ }, [dimCounts.data]);
274
+
275
+ const kpis = useSqlQuery<KpiRow[]>(
276
+ dataset,
277
+ t ? `
278
+ SELECT
279
+ SUM(balance)::BIGINT AS totalOutstanding,
280
+ SUM(CASE WHEN status IN ('current','late_30','late_60','late_90') THEN 1 ELSE 0 END)::INTEGER AS activeCount,
281
+ ROUND(AVG(amount))::INTEGER AS avgPrincipal,
282
+ SUM(CASE WHEN status = 'late_90' THEN 1 ELSE 0 END)::INTEGER AS delinquent90Count,
283
+ COUNT(*)::INTEGER AS totalCount
284
+ FROM ${t}${where}
285
+ ` : "",
286
+ [where],
287
+ );
288
+ const monthly = useSqlQuery<MonthRow[]>(
289
+ dataset,
290
+ t ? `SELECT originatedMonth, SUM(amount)::BIGINT AS vol, COUNT(*)::INTEGER AS cnt FROM ${t}${where} GROUP BY 1 ORDER BY 1` : "",
291
+ [where],
292
+ );
293
+ const statusAgg = useSqlQuery<StatusRow[]>(
294
+ dataset,
295
+ t ? `SELECT status, COUNT(*)::INTEGER AS cnt, SUM(balance)::BIGINT AS vol FROM ${t}${where} GROUP BY 1` : "",
296
+ [where],
297
+ );
298
+ const bandAgg = useSqlQuery<BandRow[]>(
299
+ dataset,
300
+ t ? `SELECT ficoBand, COUNT(*)::INTEGER AS cnt, SUM(balance)::BIGINT AS vol, ROUND(AVG(rate), 2) AS avgRate FROM ${t}${where} GROUP BY 1 ORDER BY 1` : "",
301
+ [where],
302
+ );
303
+ const stateAgg = useSqlQuery<StateRow[]>(
304
+ dataset,
305
+ t ? `SELECT state, COUNT(*)::INTEGER AS cnt, SUM(balance)::BIGINT AS vol FROM ${t}${where} GROUP BY 1 ORDER BY vol DESC LIMIT 8` : "",
306
+ [where],
307
+ );
308
+ const recent = useSqlQuery<RecentRow[]>(
309
+ dataset,
310
+ t ? `SELECT loanId, borrower, amount, fico, status, state, originated FROM ${t}${where} ORDER BY originated DESC LIMIT 8` : "",
311
+ [where],
312
+ );
313
+ // Full loan book for the table — the table handles sort/search/filter/virtualize
314
+ // client-side, so we just push the global sidebar filters and date range into SQL
315
+ // and let the table do the rest. 2,588 rows is trivial to materialize; tune LIMIT
316
+ // up if your book is bigger, or add per-column pushdown once it hurts.
317
+ const loansSql = useMemo(() => {
318
+ if (!t) return "";
319
+ return `
320
+ SELECT loanId, borrower, state, product, channel, employment,
321
+ amount, balance, fico, ficoBand, rate, term,
322
+ income, dti, status, originated, lastPay, autopay
323
+ FROM ${t}${where}
324
+ LIMIT 10000
325
+ `;
326
+ }, [t, where]);
327
+ const loansTable = useSqlQuery<Array<Record<string, unknown>>>(dataset, loansSql, [loansSql]);
328
+
329
+ const loading = kpis.loading || monthly.loading || statusAgg.loading || bandAgg.loading || stateAgg.loading || recent.loading;
330
+ const k0 = kpis.data?.[0];
331
+ const navItem = NAV_ITEMS.find((n) => n.id === view) ?? NAV_ITEMS[0];
332
+ const sparkVals = (monthly.data || []).map((m) => Number(m.cnt) || 0);
333
+
334
+ const sidebar = (
335
+ <Sidebar
336
+ logo={{ title: "Loan Portfolio", subtitle: "Fintech · Production" }}
337
+ navItems={NAV_ITEMS.map((n) => n.id === "loans" ? { ...n, badge: k0?.totalCount?.toLocaleString() } : n)}
338
+ activeView={view}
339
+ onViewChange={setView}
340
+ dateRangeSlot={
341
+ <DateRangeFilter
342
+ value={dateRange}
343
+ onChange={setDateRange}
344
+ label={null}
345
+ popoverPlacement="right-start"
346
+ triggerStyle={{ width: "100%", minWidth: 0, justifyContent: "space-between", padding: "7px 10px", fontSize: 12 }}
347
+ />
348
+ }
349
+ filterGroups={filterGroups}
350
+ filters={filters}
351
+ onFiltersChange={setFilters}
352
+ humanizeFilter={humanize}
353
+ theme={theme}
354
+ onThemeChange={onThemeChange}
355
+ footer={<>Live DuckDB · {(k0?.totalCount ?? 0).toLocaleString()} rows<br />Cached 24h TTL</>}
356
+ />
357
+ );
358
+
359
+ const headerRight = (
360
+ <>
361
+ <CachePopover
362
+ isLoading={loading}
363
+ rowCount={dataset.cache?.rowCount ?? k0?.totalCount ?? null}
364
+ cache={dataset.cache}
365
+ onRefresh={dataset.refresh}
366
+ />
367
+ <button style={{
368
+ fontSize: 12, padding: "6px 12px", borderRadius: 6,
369
+ background: P.accent, border: `1px solid ${P.accent}`, color: "#fff",
370
+ cursor: "pointer", fontWeight: 500,
371
+ }}>Export</button>
372
+ </>
373
+ );
374
+
375
+ return (
376
+ <ShellLayout
377
+ palette={P}
378
+ sidebar={sidebar}
379
+ title={navItem.label}
380
+ breadcrumb={["Portfolio", navItem.label]}
381
+ headerRight={headerRight}
382
+ >
383
+ {view === "overview" && (
384
+ <OverviewView
385
+ loading={loading}
386
+ k0={k0}
387
+ monthly={monthly.data || []}
388
+ statusAgg={statusAgg.data || []}
389
+ bandAgg={bandAgg.data || []}
390
+ recent={recent.data || []}
391
+ sparkVals={sparkVals}
392
+ dateRange={dateRange}
393
+ where={where}
394
+ />
395
+ )}
396
+ {view === "loans" && (
397
+ <LoansView loading={loansTable.loading} rows={loansTable.data || []} />
398
+ )}
399
+ {view === "risk" && (
400
+ <RiskView loading={loading} bands={bandAgg.data || []} riskMeta={riskBands.data || []} />
401
+ )}
402
+ {view === "originations" && (
403
+ <OriginationsView loading={monthly.loading} monthly={monthly.data || []} />
404
+ )}
405
+ {view === "delinquency" && (
406
+ <DelinquencyView loading={loading} statusAgg={statusAgg.data || []} k0={k0} />
407
+ )}
408
+ {view === "geography" && (
409
+ <GeographyView loading={stateAgg.loading} states={stateAgg.data || []} />
410
+ )}
411
+ </ShellLayout>
412
+ );
413
+ }
414
+
415
+ // ── Views ─────────────────────────────────────────────────────────────────
416
+
417
+ function OverviewView({ loading, k0, monthly, statusAgg, bandAgg, recent, sparkVals, dateRange, where }: {
418
+ loading: boolean;
419
+ k0: KpiRow | undefined;
420
+ monthly: MonthRow[]; statusAgg: StatusRow[]; bandAgg: BandRow[]; recent: RecentRow[];
421
+ sparkVals: number[]; dateRange: DateRangeValue; where: string;
422
+ }) {
423
+ const P = usePalette();
424
+ const drill = useDrill();
425
+ const totalStatus = statusAgg.reduce((s, r) => s + Number(r.vol || 0), 0) || 1;
426
+ const totalCount = Number(k0?.totalCount ?? 0);
427
+ const pctDelinq = totalCount > 0 ? (Number(k0?.delinquent90Count ?? 0) / totalCount) * 100 : 0;
428
+ const cardStyle: React.CSSProperties = { background: P.card, border: `1px solid ${P.border}`, borderRadius: 10 };
429
+
430
+ const kpiSpec = [
431
+ {
432
+ id: "total_outstanding", title: "Total outstanding",
433
+ value: fmtMoney(k0?.totalOutstanding ?? 0),
434
+ delta: "+8.4%", up: true, sub: "vs. prior period", accent: P.accent,
435
+ stats: [
436
+ ["Outstanding", fmtMoneyFull(k0?.totalOutstanding ?? 0)],
437
+ ["Active loans", fmtNum(k0?.activeCount ?? 0)],
438
+ ["Avg per loan", fmtMoneyFull(k0?.avgPrincipal ?? 0)],
439
+ ] as Array<[string, string]>,
440
+ },
441
+ {
442
+ id: "active_loans", title: "Active loans",
443
+ value: fmtNum(k0?.activeCount ?? 0),
444
+ delta: "+312", up: true, sub: "new this quarter", accent: P.grad2,
445
+ stats: [["Active", fmtNum(k0?.activeCount ?? 0)], ["Total", fmtNum(k0?.totalCount ?? 0)]] as Array<[string, string]>,
446
+ },
447
+ {
448
+ id: "avg_principal", title: "Avg principal",
449
+ value: fmtMoney(k0?.avgPrincipal ?? 0),
450
+ delta: "+2.1%", up: true, sub: "trailing 90 days", accent: P.ok,
451
+ stats: [["Avg", fmtMoneyFull(k0?.avgPrincipal ?? 0)]] as Array<[string, string]>,
452
+ },
453
+ {
454
+ id: "delinquency", title: "90+ delinquent",
455
+ value: fmtPct(pctDelinq, 2),
456
+ delta: "+0.12pp", up: false, sub: "vs. prior month", accent: P.warn,
457
+ stats: [["90+ DPD", fmtNum(k0?.delinquent90Count ?? 0)], ["Rate", fmtPct(pctDelinq, 2)]] as Array<[string, string]>,
458
+ },
459
+ ];
460
+
461
+ const points = monthly.map((m, i) => {
462
+ const max = Math.max(1, ...monthly.map((x) => Number(x.vol) || 0));
463
+ const min = Math.min(...monthly.map((x) => Number(x.vol) || 0));
464
+ const rng = max - min || 1;
465
+ const x = monthly.length > 1 ? (i / (monthly.length - 1)) * 100 : 50;
466
+ const y = 100 - ((Number(m.vol) - min) / rng) * 80 - 10;
467
+ return { x, y };
468
+ });
469
+ const path = points.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" ");
470
+ const area = path ? `${path} L 100 100 L 0 100 Z` : "";
471
+ const peak = monthly.reduce<MonthRow | null>((acc, m) => (!acc || Number(m.vol) > Number(acc.vol) ? m : acc), null);
472
+
473
+ return (
474
+ <>
475
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginBottom: 12 }}>
476
+ {kpiSpec.map((k) => (
477
+ <SaasKpiCard
478
+ key={k.id}
479
+ title={k.title}
480
+ value={k.value}
481
+ delta={k.delta}
482
+ up={k.up}
483
+ sub={k.sub}
484
+ spark={sparkVals.slice(-12)}
485
+ accent={k.accent}
486
+ loading={loading}
487
+ onClick={() => drill.open({
488
+ kind: "kpi", id: k.id, title: k.title, value: k.value,
489
+ breadcrumb: "Overview",
490
+ stats: k.stats,
491
+ narrative: k.id === "total_outstanding"
492
+ ? "Total principal balance across active contracts. Net of paydowns, charge-offs, and new originations."
493
+ : k.id === "delinquency"
494
+ ? "Ninety-plus day delinquency rate. Concentrated in D and E bands; watch item but within model tolerance."
495
+ : undefined,
496
+ sql: `SELECT ... FROM loans${where};`,
497
+ breakdown: monthly.slice(-12).map((m) => ({ label: m.originatedMonth, value: Number(m.vol) })),
498
+ })}
499
+ />
500
+ ))}
501
+ </div>
502
+
503
+ <div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 12, marginBottom: 12 }}>
504
+ <div
505
+ onClick={() => drill.open({
506
+ kind: "chart", id: "origination_chart", title: "Origination volume",
507
+ value: peak ? fmtMoney(peak.vol) : undefined,
508
+ subvalue: peak ? `peak · ${peak.originatedMonth}` : undefined,
509
+ breadcrumb: "Overview",
510
+ breakdown: monthly.slice(-12).map((m) => ({ label: m.originatedMonth, value: Number(m.vol) })),
511
+ sql: `SELECT originatedMonth, SUM(amount) AS vol, COUNT(*) AS cnt\nFROM loans${where}\nGROUP BY 1 ORDER BY 1;`,
512
+ narrative: "Monthly origination volume over the selected window. Click a bar to inspect that month.",
513
+ })}
514
+ style={{ ...cardStyle, padding: 18, cursor: "pointer" }}
515
+ >
516
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 16 }}>
517
+ <div>
518
+ <div style={{ fontSize: 13, fontWeight: 600 }}>Origination volume</div>
519
+ <div style={{ fontSize: 11, color: P.dim, marginTop: 2 }}>{dateRange.label} · monthly</div>
520
+ </div>
521
+ <div style={{ fontSize: 11, color: P.sub, fontFamily: P.mono }}>peak {peak ? fmtMoney(peak.vol) : "—"}</div>
522
+ </div>
523
+ <div style={{ height: 200, position: "relative" }}>
524
+ {loading ? <SkeletonShimmer width="100%" height={200} radius={6} /> : (
525
+ <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: "100%", height: "100%" }}>
526
+ <defs>
527
+ <linearGradient id="rv2Grad" x1="0" x2="0" y1="0" y2="1">
528
+ <stop offset="0%" stopColor={P.accent} stopOpacity="0.28" />
529
+ <stop offset="100%" stopColor={P.accent} stopOpacity="0" />
530
+ </linearGradient>
531
+ </defs>
532
+ {[25, 50, 75].map((y) => (
533
+ <line key={y} x1="0" y1={y} x2="100" y2={y} stroke={P.rule} strokeWidth={0.3} vectorEffect="non-scaling-stroke" />
534
+ ))}
535
+ {area ? <path d={area} fill="url(#rv2Grad)" /> : null}
536
+ {path ? <path d={path} stroke={P.accent} strokeWidth={0.6} fill="none" vectorEffect="non-scaling-stroke" /> : null}
537
+ </svg>
538
+ )}
539
+ </div>
540
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 8, fontFamily: P.mono, fontSize: 10, color: P.faint }}>
541
+ {monthly.filter((_, i) => i % Math.max(1, Math.floor(monthly.length / 6)) === 0).map((o, i) => (
542
+ <span key={i}>{o.originatedMonth.slice(2)}</span>
543
+ ))}
544
+ </div>
545
+ </div>
546
+
547
+ <div style={{ ...cardStyle, padding: 18 }}>
548
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Loan status</div>
549
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 18 }}>Portfolio composition</div>
550
+ {loading
551
+ ? Array.from({ length: 5 }).map((_, i) => (
552
+ <div key={i} style={{ marginBottom: 10 }}><SkeletonShimmer width="100%" height={14} /></div>
553
+ ))
554
+ : statusAgg.map((s) => {
555
+ const pct = (Number(s.vol) / totalStatus) * 100;
556
+ const c = statusTone(s.status, P.ok, P.warn, P.bad, P.dim);
557
+ return (
558
+ <div key={s.status}
559
+ onClick={() => drill.open({
560
+ kind: "chart", id: `status_${s.status}`,
561
+ title: s.status.replace(/_/g, " "),
562
+ value: fmtMoney(s.vol),
563
+ subvalue: `${fmtNum(s.cnt)} loans · ${pct.toFixed(1)}%`,
564
+ breadcrumb: "Overview / Status",
565
+ stats: [
566
+ ["Contracts", fmtNum(s.cnt)],
567
+ ["Volume", fmtMoneyFull(s.vol)],
568
+ ["Share", pct.toFixed(2) + "%"],
569
+ ],
570
+ })}
571
+ style={{ marginBottom: 10, cursor: "pointer" }}
572
+ onMouseEnter={(e) => (e.currentTarget.style.opacity = "0.75")}
573
+ onMouseLeave={(e) => (e.currentTarget.style.opacity = "1")}
574
+ >
575
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 4 }}>
576
+ <span style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 12 }}>
577
+ <span style={{ width: 6, height: 6, borderRadius: "50%", background: c }} />
578
+ {s.status.replace(/_/g, " ")}
579
+ </span>
580
+ <span style={{ fontFamily: P.mono, fontSize: 11, color: P.sub }}>
581
+ {fmtMoney(s.vol)} <span style={{ color: P.faint }}>· {pct.toFixed(1)}%</span>
582
+ </span>
583
+ </div>
584
+ <div style={{ height: 4, background: P.elev, borderRadius: 2, overflow: "hidden" }}>
585
+ <div style={{ height: "100%", width: `${pct}%`, background: c, borderRadius: 2 }} />
586
+ </div>
587
+ </div>
588
+ );
589
+ })}
590
+ </div>
591
+ </div>
592
+
593
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1.3fr", gap: 12 }}>
594
+ <div style={{ ...cardStyle, padding: 18 }}>
595
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Risk by band</div>
596
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 14 }}>Click a row to drill</div>
597
+ {loading
598
+ ? Array.from({ length: 5 }).map((_, i) => <div key={i} style={{ marginBottom: 8 }}><SkeletonShimmer width="100%" height={26} /></div>)
599
+ : bandAgg.map((b) => {
600
+ const pct = totalCount > 0 ? (Number(b.cnt) / totalCount) * 100 : 0;
601
+ const color = FICO_SWATCH[b.ficoBand] || P.accent;
602
+ return (
603
+ <div key={b.ficoBand}
604
+ onClick={() => drill.open({
605
+ kind: "row", id: `band_${b.ficoBand}`,
606
+ title: `Band ${b.ficoBand}`,
607
+ value: `${fmtNum(b.cnt)} loans`,
608
+ subvalue: `Avg APR ${Number(b.avgRate).toFixed(2)}% · ${fmtMoney(b.vol)} outstanding`,
609
+ breadcrumb: "Risk / Band",
610
+ stats: [
611
+ ["Loans", fmtNum(b.cnt)],
612
+ ["Volume", fmtMoneyFull(b.vol)],
613
+ ["Avg APR", Number(b.avgRate).toFixed(2) + "%"],
614
+ ],
615
+ })}
616
+ style={{
617
+ display: "grid", gridTemplateColumns: "28px 1fr 60px",
618
+ alignItems: "center", gap: 10, padding: "9px 8px",
619
+ borderRadius: 6, cursor: "pointer",
620
+ }}
621
+ onMouseEnter={(e) => (e.currentTarget.style.background = P.elev)}
622
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
623
+ >
624
+ <div style={{
625
+ width: 24, height: 24, borderRadius: 5, background: color,
626
+ display: "flex", alignItems: "center", justifyContent: "center",
627
+ fontSize: 12, fontWeight: 700, color: "#fff",
628
+ }}>{b.ficoBand}</div>
629
+ <div style={{ height: 6, background: P.elev, borderRadius: 3, overflow: "hidden" }}>
630
+ <div style={{ height: "100%", width: `${Math.min(100, pct * 3)}%`, background: color, borderRadius: 3 }} />
631
+ </div>
632
+ <div style={{ textAlign: "right", fontFamily: P.mono, fontSize: 12, color: P.text }}>{fmtNum(b.cnt)}</div>
633
+ </div>
634
+ );
635
+ })}
636
+ </div>
637
+
638
+ <div style={{ ...cardStyle, overflow: "hidden" }}>
639
+ <div style={{ padding: "16px 18px", borderBottom: `1px solid ${P.border}` }}>
640
+ <div style={{ fontSize: 13, fontWeight: 600 }}>Recent originations</div>
641
+ <div style={{ fontSize: 11, color: P.dim, marginTop: 2 }}>Last {recent.length} closings · click row to drill</div>
642
+ </div>
643
+ <table style={{ width: "100%", fontSize: 12, borderCollapse: "collapse" }}>
644
+ <thead>
645
+ <tr style={{ color: P.dim, background: P.elev }}>
646
+ <th style={{ textAlign: "left", padding: "8px 18px", fontWeight: 500, fontSize: 11 }}>Loan</th>
647
+ <th style={{ textAlign: "left", padding: "8px 12px", fontWeight: 500, fontSize: 11 }}>Borrower</th>
648
+ <th style={{ textAlign: "right", padding: "8px 12px", fontWeight: 500, fontSize: 11 }}>Amount</th>
649
+ <th style={{ textAlign: "right", padding: "8px 12px", fontWeight: 500, fontSize: 11 }}>FICO</th>
650
+ <th style={{ textAlign: "left", padding: "8px 18px", fontWeight: 500, fontSize: 11 }}>Status</th>
651
+ </tr>
652
+ </thead>
653
+ <tbody>
654
+ {loading
655
+ ? Array.from({ length: 6 }).map((_, i) => (
656
+ <tr key={i}><td colSpan={5} style={{ padding: "10px 18px" }}><SkeletonShimmer width="100%" height={14} /></td></tr>
657
+ ))
658
+ : recent.map((r) => {
659
+ const c = statusTone(r.status, P.ok, P.warn, P.bad, P.dim);
660
+ const bg = statusSoft(r.status, P.okSoft, P.warnSoft, P.badSoft, P.elev);
661
+ return (
662
+ <tr key={r.loanId}
663
+ onClick={() => drill.open({
664
+ kind: "row", id: `loan_${r.loanId}`,
665
+ title: `${r.loanId} — ${r.borrower}`,
666
+ value: fmtMoneyFull(r.amount),
667
+ subvalue: `FICO ${r.fico} · ${r.state} · ${r.originated}`,
668
+ breadcrumb: "Overview / Loan",
669
+ stats: [
670
+ ["Amount", fmtMoneyFull(r.amount)],
671
+ ["FICO", String(r.fico)],
672
+ ["State", r.state],
673
+ ["Status", r.status.replace(/_/g, " ")],
674
+ ["Originated", r.originated],
675
+ ],
676
+ })}
677
+ style={{ borderTop: `1px solid ${P.border}`, cursor: "pointer" }}
678
+ onMouseEnter={(e) => (e.currentTarget.style.background = P.elev)}
679
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
680
+ >
681
+ <td style={{ padding: "10px 18px", fontFamily: P.mono, fontSize: 11, color: P.sub }}>{r.loanId}</td>
682
+ <td style={{ padding: "10px 12px" }}>{r.borrower}</td>
683
+ <td style={{ padding: "10px 12px", textAlign: "right", fontFamily: P.mono, fontWeight: 500 }}>{fmtMoneyFull(r.amount)}</td>
684
+ <td style={{ padding: "10px 12px", textAlign: "right", fontFamily: P.mono, color: r.fico < 650 ? P.bad : P.sub }}>{r.fico}</td>
685
+ <td style={{ padding: "10px 18px" }}>
686
+ <span style={{
687
+ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 11,
688
+ padding: "2px 7px", borderRadius: 10, background: bg, color: c, fontWeight: 500,
689
+ }}>
690
+ <span style={{ width: 5, height: 5, borderRadius: "50%", background: c }} />
691
+ {r.status.replace(/_/g, " ")}
692
+ </span>
693
+ </td>
694
+ </tr>
695
+ );
696
+ })}
697
+ </tbody>
698
+ </table>
699
+ </div>
700
+ </div>
701
+ </>
702
+ );
703
+ }
704
+
705
+ type LoanRow = {
706
+ loanId: string;
707
+ borrower: string;
708
+ state: string;
709
+ product: string;
710
+ channel: string;
711
+ employment: string;
712
+ amount: number;
713
+ balance: number;
714
+ fico: number;
715
+ ficoBand: string;
716
+ rate: number;
717
+ term: number;
718
+ income: number;
719
+ dti: number;
720
+ status: string;
721
+ originated: string;
722
+ lastPay: string;
723
+ autopay: boolean;
724
+ };
725
+
726
+ function LoansView({ loading, rows }: { loading: boolean; rows: Array<Record<string, unknown>> }) {
727
+ const P = usePalette();
728
+ const drill = useDrill();
729
+
730
+ const columns = useMemo<SaasDataTableColumn<LoanRow>[]>(() => [
731
+ { key: "loanId", label: "Loan ID", width: 110, kind: "string", mono: true },
732
+ { key: "borrower", label: "Borrower", width: 140, kind: "string" },
733
+ { key: "state", label: "State", width: 72, kind: "enum", mono: true },
734
+ { key: "product", label: "Product", width: 130, kind: "enum",
735
+ enumLabels: { personal: "Personal", consolidation: "Consolidation", auto: "Auto-secured", home: "Home improvement" } },
736
+ { key: "channel", label: "Channel", width: 120, kind: "enum",
737
+ enumLabels: { direct_mail: "Direct mail", paid_search: "Paid search", affiliate: "Affiliate", organic: "Organic", partner: "Partner API" } },
738
+ { key: "amount", label: "Amount", width: 100, kind: "money", align: "right", mono: true },
739
+ { key: "balance", label: "Balance", width: 100, kind: "money", align: "right", mono: true },
740
+ { key: "fico", label: "FICO", width: 72, kind: "number", align: "right", mono: true,
741
+ render: (v) => <span style={{ color: Number(v) < 650 ? P.bad : P.sub }}>{String(v)}</span> },
742
+ { key: "ficoBand", label: "Band", width: 70, kind: "enum", mono: true,
743
+ render: (v) => {
744
+ const color = FICO_SWATCH[String(v)] || P.accent;
745
+ return (
746
+ <span style={{
747
+ display: "inline-flex", alignItems: "center", justifyContent: "center",
748
+ width: 20, height: 20, borderRadius: 4, background: color,
749
+ color: "#fff", fontSize: 11, fontWeight: 700,
750
+ }}>{String(v)}</span>
751
+ );
752
+ },
753
+ },
754
+ { key: "rate", label: "APR", width: 75, kind: "rate", align: "right", mono: true,
755
+ format: (v) => `${Number(v).toFixed(2)}%` },
756
+ { key: "term", label: "Term", width: 70, kind: "number", align: "right", mono: true,
757
+ format: (v) => `${v}m` },
758
+ { key: "income", label: "Income", width: 100, kind: "money", align: "right", mono: true },
759
+ { key: "dti", label: "DTI", width: 70, kind: "rate", align: "right", mono: true },
760
+ { key: "status", label: "Status", width: 120, kind: "enum",
761
+ enumLabels: {
762
+ current: "Current", late_30: "30 days late", late_60: "60 days late",
763
+ late_90: "90+ days late", paid_off: "Paid off", charged_off: "Charged off",
764
+ },
765
+ render: (v) => {
766
+ const s = String(v);
767
+ const c = statusTone(s, P.ok, P.warn, P.bad, P.dim);
768
+ const bg = statusSoft(s, P.okSoft, P.warnSoft, P.badSoft, P.elev);
769
+ return (
770
+ <span style={{
771
+ display: "inline-flex", alignItems: "center", gap: 5, fontSize: 11,
772
+ padding: "2px 7px", borderRadius: 10, background: bg, color: c, fontWeight: 500,
773
+ }}>
774
+ <span style={{ width: 5, height: 5, borderRadius: "50%", background: c }} />
775
+ {s.replace(/_/g, " ")}
776
+ </span>
777
+ );
778
+ },
779
+ },
780
+ { key: "originated", label: "Originated", width: 110, kind: "date", mono: true },
781
+ { key: "lastPay", label: "Last pay", width: 110, kind: "date", mono: true },
782
+ { key: "autopay", label: "Autopay", width: 85, kind: "bool", align: "center" },
783
+ ], [P]);
784
+
785
+ const loanRows = rows as unknown as LoanRow[];
786
+
787
+ if (loading && loanRows.length === 0) {
788
+ return (
789
+ <div style={{
790
+ background: P.card, border: `1px solid ${P.border}`, borderRadius: 10,
791
+ padding: 40, textAlign: "center", color: P.dim, fontSize: 12,
792
+ }}>Loading loan book…</div>
793
+ );
794
+ }
795
+
796
+ return (
797
+ <div style={{ display: "flex", flexDirection: "column", gap: 12, height: "calc(100vh - 150px)" }}>
798
+ <div>
799
+ <div style={{ fontSize: 20, fontWeight: 600, letterSpacing: "-0.01em" }}>Loan book</div>
800
+ <div style={{ fontSize: 12, color: P.sub, marginTop: 2 }}>
801
+ Loan-level detail · virtualized · sort, filter, or search any column
802
+ </div>
803
+ </div>
804
+ <div style={{ flex: 1, minHeight: 0 }}>
805
+ <SaasDataTable<LoanRow>
806
+ columns={columns}
807
+ rows={loanRows}
808
+ rowKey={(r) => r.loanId}
809
+ defaultSort={{ key: "originated", dir: "desc" }}
810
+ searchPlaceholder="Search all columns…"
811
+ widthStorageKey="loan-portfolio.loans-table.widths"
812
+ aggregates={(visibleRows) => {
813
+ if (visibleRows.length === 0) return { rows: "0" };
814
+ const amt = visibleRows.reduce((s, r) => s + (Number(r.amount) || 0), 0);
815
+ const bal = visibleRows.reduce((s, r) => s + (Number(r.balance) || 0), 0);
816
+ const ficoAvg = Math.round(visibleRows.reduce((s, r) => s + (Number(r.fico) || 0), 0) / visibleRows.length);
817
+ return {
818
+ rows: visibleRows.length.toLocaleString(),
819
+ "total amount": fmtMoney(amt),
820
+ "total balance": fmtMoney(bal),
821
+ "avg fico": String(ficoAvg),
822
+ };
823
+ }}
824
+ onRowClick={(row) => drill.open({
825
+ kind: "row",
826
+ id: `loan_${row.loanId}`,
827
+ title: `${row.loanId} — ${row.borrower}`,
828
+ value: fmtMoneyFull(row.amount),
829
+ subvalue: `FICO ${row.fico} · ${row.state} · ${row.originated}`,
830
+ breadcrumb: "Loans / Loan",
831
+ stats: [
832
+ ["Amount", fmtMoneyFull(row.amount)],
833
+ ["Balance", fmtMoneyFull(row.balance)],
834
+ ["FICO", String(row.fico)],
835
+ ["APR", Number(row.rate).toFixed(2) + "%"],
836
+ ["Term", String(row.term) + " mo"],
837
+ ["State", row.state],
838
+ ["Status", row.status.replace(/_/g, " ")],
839
+ ["Originated", row.originated],
840
+ ["Last pay", row.lastPay],
841
+ ],
842
+ })}
843
+ />
844
+ </div>
845
+ </div>
846
+ );
847
+ }
848
+
849
+ function RiskView({ loading, bands, riskMeta }: {
850
+ loading: boolean; bands: BandRow[];
851
+ riskMeta: Array<{ band: string; range: string; apr: number; defaultRate: number; color: string }>;
852
+ }) {
853
+ const P = usePalette();
854
+ const drill = useDrill();
855
+ const cardStyle: React.CSSProperties = { background: P.card, border: `1px solid ${P.border}`, borderRadius: 10 };
856
+ const maxCount = Math.max(1, ...bands.map((b) => Number(b.cnt) || 0));
857
+ const metaByBand = Object.fromEntries(riskMeta.map((m) => [m.band, m]));
858
+ return (
859
+ <>
860
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", gap: 12, marginBottom: 12 }}>
861
+ {loading
862
+ ? Array.from({ length: 5 }).map((_, i) => <div key={i} style={{ ...cardStyle, padding: 16 }}><SkeletonShimmer width="100%" height={90} /></div>)
863
+ : bands.map((b) => {
864
+ const meta = metaByBand[b.ficoBand];
865
+ const color = FICO_SWATCH[b.ficoBand] || P.accent;
866
+ return (
867
+ <div key={b.ficoBand}
868
+ onClick={() => drill.open({
869
+ kind: "kpi", id: `band_${b.ficoBand}`,
870
+ title: `Band ${b.ficoBand}`,
871
+ value: fmtNum(b.cnt),
872
+ subvalue: meta ? `FICO ${meta.range}` : undefined,
873
+ breadcrumb: "Risk",
874
+ stats: [
875
+ ["Contracts", fmtNum(b.cnt)],
876
+ ["Volume", fmtMoneyFull(b.vol)],
877
+ ["Avg APR", Number(b.avgRate).toFixed(2) + "%"],
878
+ ...(meta ? ([["Default rate", meta.defaultRate.toFixed(1) + "%"]] as Array<[string, string]>) : []),
879
+ ],
880
+ })}
881
+ style={{ ...cardStyle, padding: 16, cursor: "pointer" }}
882
+ >
883
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
884
+ <div style={{ width: 28, height: 28, borderRadius: 6, background: color, display: "flex", alignItems: "center", justifyContent: "center", fontSize: 13, fontWeight: 700, color: "#fff" }}>{b.ficoBand}</div>
885
+ <div style={{ fontFamily: P.mono, fontSize: 10, color: P.dim }}>{meta?.range ?? ""}</div>
886
+ </div>
887
+ <div style={{ fontSize: 24, fontWeight: 600, letterSpacing: "-0.02em" }}>{fmtNum(b.cnt)}</div>
888
+ <div style={{ fontSize: 11, color: P.dim, marginTop: 2 }}>contracts</div>
889
+ <div style={{ marginTop: 12, height: 3, background: P.elev, borderRadius: 2, overflow: "hidden" }}>
890
+ <div style={{ height: "100%", width: `${(Number(b.cnt) / maxCount) * 100}%`, background: color }} />
891
+ </div>
892
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 10, fontSize: 11 }}>
893
+ <span style={{ color: P.sub }}>APR</span>
894
+ <span style={{ fontFamily: P.mono, color: P.text }}>{Number(b.avgRate).toFixed(2)}%</span>
895
+ </div>
896
+ {meta ? (
897
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 4, fontSize: 11 }}>
898
+ <span style={{ color: P.sub }}>Default</span>
899
+ <span style={{ fontFamily: P.mono, color: meta.defaultRate > 5 ? P.bad : P.text }}>{meta.defaultRate.toFixed(1)}%</span>
900
+ </div>
901
+ ) : null}
902
+ </div>
903
+ );
904
+ })}
905
+ </div>
906
+
907
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
908
+ <div style={{ ...cardStyle, padding: 18 }}>
909
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Risk × return</div>
910
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 18 }}>Default rate vs. weighted APR · size = contracts</div>
911
+ <div style={{ position: "relative", height: 260 }}>
912
+ <svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: "100%", height: "100%" }}>
913
+ {[25, 50, 75].map((y) => <line key={"h" + y} x1="0" y1={y} x2="100" y2={y} stroke={P.rule} strokeWidth={0.3} vectorEffect="non-scaling-stroke" />)}
914
+ {[25, 50, 75].map((x) => <line key={"v" + x} x1={x} y1="0" x2={x} y2="100" stroke={P.rule} strokeWidth={0.3} vectorEffect="non-scaling-stroke" />)}
915
+ {bands.map((b) => {
916
+ const meta = metaByBand[b.ficoBand];
917
+ if (!meta) return null;
918
+ const cx = Math.min(100, Math.max(0, (Number(b.avgRate) / 20) * 100));
919
+ const cy = Math.min(100, Math.max(0, 100 - (meta.defaultRate / 15) * 100));
920
+ const r = 3 + (Number(b.cnt) / maxCount) * 8;
921
+ return <circle key={b.ficoBand} cx={cx} cy={cy} r={r} fill={FICO_SWATCH[b.ficoBand] || P.accent} opacity={0.75} />;
922
+ })}
923
+ </svg>
924
+ </div>
925
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 10, color: P.faint, fontFamily: P.mono }}>
926
+ <span>0%</span><span>APR →</span><span>20%</span>
927
+ </div>
928
+ </div>
929
+
930
+ <div style={{ ...cardStyle, padding: 18 }}>
931
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Band composition</div>
932
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 18 }}>Share of outstanding by band</div>
933
+ <div style={{ display: "flex", height: 24, borderRadius: 4, overflow: "hidden", border: `1px solid ${P.border}` }}>
934
+ {bands.map((b) => {
935
+ const total = bands.reduce((s, x) => s + Number(x.vol || 0), 0) || 1;
936
+ const pct = (Number(b.vol) / total) * 100;
937
+ return <div key={b.ficoBand} title={`${b.ficoBand}: ${pct.toFixed(1)}%`} style={{ width: `${pct}%`, background: FICO_SWATCH[b.ficoBand] || P.accent }} />;
938
+ })}
939
+ </div>
940
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 10, marginTop: 12 }}>
941
+ {bands.map((b) => {
942
+ const total = bands.reduce((s, x) => s + Number(x.vol || 0), 0) || 1;
943
+ const pct = (Number(b.vol) / total) * 100;
944
+ return (
945
+ <div key={b.ficoBand} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: P.sub }}>
946
+ <span style={{ width: 8, height: 8, borderRadius: 2, background: FICO_SWATCH[b.ficoBand] || P.accent }} />
947
+ <span style={{ fontFamily: P.mono }}>{b.ficoBand} · {pct.toFixed(1)}%</span>
948
+ </div>
949
+ );
950
+ })}
951
+ </div>
952
+ </div>
953
+ </div>
954
+ </>
955
+ );
956
+ }
957
+
958
+ function OriginationsView({ loading, monthly }: { loading: boolean; monthly: MonthRow[] }) {
959
+ const P = usePalette();
960
+ const drill = useDrill();
961
+ const cardStyle: React.CSSProperties = { background: P.card, border: `1px solid ${P.border}`, borderRadius: 10 };
962
+ const totalVol = monthly.reduce((s, m) => s + Number(m.vol || 0), 0);
963
+ const maxMonthVol = Math.max(1, ...monthly.map((m) => Number(m.vol) || 0));
964
+ const totalCnt = monthly.reduce((s, m) => s + Number(m.cnt || 0), 0);
965
+ const lastMonthVol = monthly.slice(-1)[0]?.vol ?? 0;
966
+
967
+ const kpis = [
968
+ { id: "l_vol", label: "Period volume", value: fmtMoney(totalVol), delta: "+18%", up: true },
969
+ { id: "new_cnt", label: "New contracts", value: fmtNum(totalCnt), delta: "+312", up: true },
970
+ { id: "last_vol", label: "Most recent month", value: fmtMoney(lastMonthVol) },
971
+ ];
972
+
973
+ return (
974
+ <>
975
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 12, marginBottom: 12 }}>
976
+ {kpis.map((k) => (
977
+ <div key={k.id} style={{ ...cardStyle, padding: 16 }}>
978
+ <div style={{ fontSize: 12, color: P.sub, fontWeight: 500 }}>{k.label}</div>
979
+ <div style={{ fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em", marginTop: 8 }}>{k.value}</div>
980
+ {k.delta ? (
981
+ <div style={{ fontSize: 11, marginTop: 8, color: k.up ? P.ok : P.bad, fontFamily: P.mono }}>
982
+ {k.up ? "↑" : "↓"} {k.delta}
983
+ </div>
984
+ ) : null}
985
+ </div>
986
+ ))}
987
+ </div>
988
+ <div
989
+ onClick={() => drill.open({
990
+ kind: "chart", id: "origination_timeline", title: "Origination timeline",
991
+ value: fmtMoney(totalVol), breadcrumb: "Originations",
992
+ breakdown: monthly.map((m) => ({ label: m.originatedMonth, value: Number(m.vol) })),
993
+ narrative: "Monthly origination volume over the selected date range.",
994
+ })}
995
+ style={{ ...cardStyle, padding: 18, cursor: "pointer" }}
996
+ >
997
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Origination timeline</div>
998
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 18 }}>Monthly volume · {monthly.length} months</div>
999
+ <div style={{ display: "flex", alignItems: "flex-end", gap: 2, height: 220 }}>
1000
+ {loading ? <SkeletonShimmer width="100%" height={220} radius={4} /> : monthly.map((o, i) => {
1001
+ const h = (Number(o.vol) / maxMonthVol) * 100;
1002
+ const isRecent = i >= monthly.length - 12;
1003
+ return <div key={i} title={`${o.originatedMonth}: ${fmtMoney(o.vol)}`}
1004
+ style={{ flex: 1, height: `${h}%`, background: isRecent ? P.accent : P.accentSoft, opacity: isRecent ? 1 : 0.7, borderRadius: "2px 2px 0 0" }} />;
1005
+ })}
1006
+ </div>
1007
+ <div style={{ display: "flex", justifyContent: "space-between", marginTop: 8, fontSize: 10, color: P.faint, fontFamily: P.mono }}>
1008
+ <span>{monthly[0]?.originatedMonth ?? ""}</span>
1009
+ <span>{monthly[monthly.length - 1]?.originatedMonth ?? ""}</span>
1010
+ </div>
1011
+ </div>
1012
+ </>
1013
+ );
1014
+ }
1015
+
1016
+ function DelinquencyView({ loading, statusAgg, k0 }: { loading: boolean; statusAgg: StatusRow[]; k0: KpiRow | undefined }) {
1017
+ const P = usePalette();
1018
+ const drill = useDrill();
1019
+ const cardStyle: React.CSSProperties = { background: P.card, border: `1px solid ${P.border}`, borderRadius: 10 };
1020
+ const delinq = statusAgg.filter((s) => String(s.status).includes("late") || s.status === "charged_off");
1021
+ const pct = k0?.totalCount ? (Number(k0.delinquent90Count ?? 0) / Number(k0.totalCount)) * 100 : 0;
1022
+
1023
+ return (
1024
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))", gap: 12 }}>
1025
+ {loading ? (
1026
+ Array.from({ length: 5 }).map((_, i) => <div key={i} style={{ ...cardStyle, padding: 16 }}><SkeletonShimmer width="100%" height={70} /></div>)
1027
+ ) : (
1028
+ <>
1029
+ {delinq.map((s) => {
1030
+ const c = statusTone(s.status, P.ok, P.warn, P.bad, P.dim);
1031
+ return (
1032
+ <div key={s.status}
1033
+ onClick={() => drill.open({
1034
+ kind: "kpi", id: `status_${s.status}`,
1035
+ title: s.status.replace(/_/g, " "),
1036
+ value: fmtNum(s.cnt),
1037
+ subvalue: fmtMoney(s.vol),
1038
+ breadcrumb: "Delinquency",
1039
+ stats: [["Contracts", fmtNum(s.cnt)], ["Volume", fmtMoneyFull(s.vol)]],
1040
+ })}
1041
+ style={{ ...cardStyle, padding: 16, borderLeft: `3px solid ${c}`, cursor: "pointer" }}
1042
+ >
1043
+ <div style={{ fontSize: 12, color: P.sub, fontWeight: 500 }}>{s.status.replace(/_/g, " ")}</div>
1044
+ <div style={{ fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em", marginTop: 8, color: c }}>{fmtNum(s.cnt)}</div>
1045
+ <div style={{ fontSize: 11, color: P.dim, marginTop: 6, fontFamily: P.mono }}>{fmtMoney(s.vol)}</div>
1046
+ </div>
1047
+ );
1048
+ })}
1049
+ <div style={{ ...cardStyle, padding: 16, borderLeft: `3px solid ${P.warn}` }}>
1050
+ <div style={{ fontSize: 12, color: P.sub, fontWeight: 500 }}>90+ DPD rate</div>
1051
+ <div style={{ fontSize: 26, fontWeight: 600, letterSpacing: "-0.02em", marginTop: 8, color: P.warn }}>{pct.toFixed(2)}%</div>
1052
+ <div style={{ fontSize: 11, color: P.bad, marginTop: 6, fontFamily: P.mono }}>↑ +0.12pp MoM</div>
1053
+ </div>
1054
+ </>
1055
+ )}
1056
+ </div>
1057
+ );
1058
+ }
1059
+
1060
+ function GeographyView({ loading, states }: { loading: boolean; states: StateRow[] }) {
1061
+ const P = usePalette();
1062
+ const drill = useDrill();
1063
+ const cardStyle: React.CSSProperties = { background: P.card, border: `1px solid ${P.border}`, borderRadius: 10 };
1064
+ const maxVol = Math.max(1, ...states.map((s) => Number(s.vol) || 0));
1065
+ return (
1066
+ <div style={{ ...cardStyle, padding: 18 }}>
1067
+ <div style={{ fontSize: 13, fontWeight: 600, marginBottom: 4 }}>Top 8 states</div>
1068
+ <div style={{ fontSize: 11, color: P.dim, marginBottom: 18 }}>Ranked by total volume · click to drill</div>
1069
+ {loading
1070
+ ? Array.from({ length: 8 }).map((_, i) => <div key={i} style={{ marginBottom: 8 }}><SkeletonShimmer width="100%" height={22} /></div>)
1071
+ : states.map((s, i) => (
1072
+ <div key={s.state}
1073
+ onClick={() => drill.open({
1074
+ kind: "row", id: `state_${s.state}`,
1075
+ title: `State · ${s.state}`,
1076
+ value: fmtMoney(s.vol),
1077
+ subvalue: `${fmtNum(s.cnt)} loans`,
1078
+ breadcrumb: "Geography",
1079
+ stats: [["Loans", fmtNum(s.cnt)], ["Volume", fmtMoneyFull(s.vol)]],
1080
+ })}
1081
+ style={{
1082
+ display: "grid", gridTemplateColumns: "20px 50px 1fr 80px 60px",
1083
+ alignItems: "center", gap: 12, padding: "8px 6px", borderRadius: 6, cursor: "pointer",
1084
+ }}
1085
+ onMouseEnter={(e) => (e.currentTarget.style.background = P.elev)}
1086
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
1087
+ >
1088
+ <span style={{ fontFamily: P.mono, fontSize: 11, color: P.faint }}>#{i + 1}</span>
1089
+ <span style={{ fontFamily: P.mono, fontSize: 12, fontWeight: 600 }}>{s.state}</span>
1090
+ <div style={{ height: 6, background: P.elev, borderRadius: 3, overflow: "hidden" }}>
1091
+ <div style={{ height: "100%", width: `${(Number(s.vol) / maxVol) * 100}%`, background: P.accent, borderRadius: 3 }} />
1092
+ </div>
1093
+ <span style={{ fontFamily: P.mono, fontSize: 12, textAlign: "right" }}>{fmtMoney(s.vol)}</span>
1094
+ <span style={{ fontFamily: P.mono, fontSize: 11, color: P.dim, textAlign: "right" }}>{fmtNum(s.cnt)} loans</span>
1095
+ </div>
1096
+ ))}
1097
+ </div>
1098
+ );
1099
+ }
1100
+
1101
+ // Re-exports used to silence unused-import warnings for primitives that
1102
+ // downstream apps consume through this file's import map.
1103
+ void Sparkline; void Breadcrumb;