akefin-design-system/ui_kits/web/DashboardScreen.jsx

493 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// DashboardScreen — overview: KPI cards + charts
// All data derived from window.AKEFIN_DATA where possible.
(function () {
// ---- Synthesised series for charts (deterministic; based on the seed) --
// 30-day cashflow (income/expense in KRW equivalent). Negative = spend.
const CASHFLOW = (() => {
// Pseudo-random but stable. Mix of small-spend days, larger ones, and one big income.
const seedSpend = [42, 86, 31, 124, 58, 73, 18, 96, 240, 64, 47, 39, 28, 71, 110, 56, 33, 88, 49, 62, 38, 145, 72, 80, 41, 53, 117, 64, 91, 38]; // ×1000 KRW
const seedIncomeIdx = { 4: 2400, 12: 1240, 19: 1180, 27: 2400 }; // ×1000 KRW positive on these indexes
return seedSpend.map((s, i) => ({
day: i + 1,
spend: -s * 1000,
income: (seedIncomeIdx[i] || 0) * 1000,
}));
})();
// Spend by category (current month, KRW)
const CATEGORIES = [
{ name: "Groceries", amount: 481600, entity: "personal" },
{ name: "Food · Delivery", amount: 224000, entity: "personal" },
{ name: "Housing · Maint", amount: 286000, entity: "personal" },
{ name: "Food · Coffee", amount: 84600, entity: "personal" },
{ name: "Convenience", amount: 62400, entity: "personal" },
{ name: "Software", amount: 96000, entity: "tfox" },
{ name: "Infrastructure", amount: 42800, entity: "tfox" },
{ name: "Transit", amount: 38000, entity: "personal" },
{ name: "Clothing", amount: 28400, entity: "personal" },
];
// Confidence distribution (count of transactions currently staged across history)
const CONFIDENCE_DIST = [
{ tier: "rules", count: 287, label: "Rules" },
{ tier: "high", count: 124, label: "LLM ≥ 0.85"},
{ tier: "mid", count: 46, label: "0.700.85" },
{ tier: "low", count: 18, label: "< 0.70" },
];
// Sparkline series for KPI cards (last 14 points)
const SPARK = {
staged: [12, 18, 21, 16, 24, 29, 22, 25, 31, 27, 23, 28, 19, 23],
spend: [128, 142, 119, 156, 138, 161, 149, 152, 174, 168, 145, 159, 138, 142], // ×1000 KRW
income: [0, 0, 2400, 0, 0, 1240, 0, 0, 0, 2400, 0, 1180, 0, 0],
conf: [0.81, 0.83, 0.79, 0.84, 0.86, 0.85, 0.87, 0.88, 0.86, 0.89, 0.87, 0.88, 0.90, 0.89],
};
// ---- Pure SVG components ----------------------------------------------
function Sparkline({ data, color = "var(--fg-muted)", height = 28, width = 96, fill = false }) {
if (!data || data.length === 0) return null;
const min = Math.min(...data, 0);
const max = Math.max(...data, 0.0001);
const span = max - min || 1;
const stepX = width / (data.length - 1);
const points = data.map((v, i) => [i * stepX, height - ((v - min) / span) * (height - 2) - 1]);
const d = points.map((p, i) => (i === 0 ? `M${p[0]},${p[1]}` : `L${p[0]},${p[1]}`)).join(" ");
const dFill = `${d} L${width},${height} L0,${height} Z`;
return (
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="spark">
{fill && <path d={dFill} fill={color} fillOpacity="0.10" />}
<path d={d} fill="none" stroke={color} strokeWidth="1.25" strokeLinejoin="round" strokeLinecap="round" />
{/* end dot */}
<circle cx={points[points.length - 1][0]} cy={points[points.length - 1][1]} r="2" fill={color} />
</svg>
);
}
function StatCard({ eyebrow, value, unit, delta, deltaDir, spark, sparkColor, footnote }) {
return (
<div className="dash-stat">
<div className="dash-stat-eyebrow">{eyebrow}</div>
<div className="dash-stat-value-row">
<div className="dash-stat-value">
{value}
{unit && <span className="dash-stat-unit"> {unit}</span>}
</div>
{spark && <Sparkline data={spark} color={sparkColor || "var(--fg-muted)"} height={28} width={86} fill />}
</div>
<div className="dash-stat-foot">
{delta && (
<span className={`dash-delta ${deltaDir}`}>
<span className="arrow">{deltaDir === "up" ? "↑" : deltaDir === "down" ? "↓" : "·"}</span>
{delta}
</span>
)}
{footnote && <span className="dash-stat-foot-note">{footnote}</span>}
</div>
</div>
);
}
// Cashflow bar chart — bars per day, income up (green) / spend down (ink), 30 days
function CashflowChart({ data }) {
const W = 720, H = 220, PAD_L = 44, PAD_R = 12, PAD_T = 16, PAD_B = 28;
const plotW = W - PAD_L - PAD_R;
const plotH = H - PAD_T - PAD_B;
const maxPos = Math.max(...data.map(d => d.income), 1);
const maxNeg = Math.max(...data.map(d => -d.spend), 1);
const maxAbs = Math.max(maxPos, maxNeg);
const zeroY = PAD_T + plotH / 2;
const half = plotH / 2;
const colW = plotW / data.length;
const barW = Math.max(3, colW * 0.55);
const yScale = (v) => zeroY - (v / maxAbs) * half;
// Y-axis ticks: 0, ±50%, ±100%
const ticks = [-1, -0.5, 0, 0.5, 1];
const fmtTick = (frac) => {
if (frac === 0) return "0";
const v = Math.round(frac * maxAbs / 1000);
return `${frac > 0 ? "+" : ""}${Math.abs(v).toLocaleString()}k`;
};
return (
<svg viewBox={`0 0 ${W} ${H}`} className="chart-svg" preserveAspectRatio="none">
{/* Gridlines */}
{ticks.map(t => (
<g key={t}>
<line x1={PAD_L} x2={W - PAD_R} y1={yScale(t * maxAbs)} y2={yScale(t * maxAbs)}
stroke="var(--rule)" strokeWidth="1" strokeDasharray={t === 0 ? "" : "2 3"} />
<text x={PAD_L - 8} y={yScale(t * maxAbs) + 3.5} textAnchor="end"
className="axis-label">{fmtTick(t)}</text>
</g>
))}
{/* Bars */}
{data.map((d, i) => {
const cx = PAD_L + i * colW + colW / 2;
const sH = (Math.abs(d.spend) / maxAbs) * half;
const iH = (d.income / maxAbs) * half;
return (
<g key={i}>
{d.spend !== 0 && (
<rect x={cx - barW / 2} y={zeroY} width={barW} height={sH}
fill="var(--fg-strong)" />
)}
{d.income !== 0 && (
<rect x={cx - barW / 2} y={zeroY - iH} width={barW} height={iH}
fill="var(--conf-rules)" />
)}
</g>
);
})}
{/* X axis labels (every 5 days) */}
{data.map((d, i) => {
if (i % 5 !== 0 && i !== data.length - 1) return null;
const cx = PAD_L + i * colW + colW / 2;
return (
<text key={`x${i}`} x={cx} y={H - 10} textAnchor="middle" className="axis-label">
Feb {6 + d.day}
</text>
);
})}
</svg>
);
}
// Donut for confidence distribution
function Donut({ data, size = 168, thickness = 22 }) {
const total = data.reduce((a, b) => a + b.count, 0);
const r = (size - thickness) / 2;
const cx = size / 2, cy = size / 2;
const colors = {
rules: "var(--conf-rules)",
high: "var(--conf-high)",
mid: "var(--conf-mid)",
low: "var(--conf-low)",
};
let cum = 0;
const segs = data.map(d => {
const start = cum / total;
cum += d.count;
const end = cum / total;
return { ...d, start, end };
});
const arc = (s, e) => {
const a0 = s * Math.PI * 2 - Math.PI / 2;
const a1 = e * Math.PI * 2 - Math.PI / 2;
const x0 = cx + r * Math.cos(a0), y0 = cy + r * Math.sin(a0);
const x1 = cx + r * Math.cos(a1), y1 = cy + r * Math.sin(a1);
const large = e - s > 0.5 ? 1 : 0;
return `M${x0},${y0} A${r},${r} 0 ${large} 1 ${x1},${y1}`;
};
return (
<svg viewBox={`0 0 ${size} ${size}`} width={size} height={size} className="donut">
{/* track */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--rule)" strokeWidth={thickness} />
{segs.map((s, i) => (
<path key={i} d={arc(s.start, s.end - 0.003)} stroke={colors[s.tier]}
strokeWidth={thickness} fill="none" strokeLinecap="butt" />
))}
<text x={cx} y={cy - 4} textAnchor="middle" className="donut-num">{total}</text>
<text x={cx} y={cy + 14} textAnchor="middle" className="donut-lbl">CATEGORIZED · MAR</text>
</svg>
);
}
// ---- Sub-cards --------------------------------------------------------
function CashflowCard() {
const totalIn = CASHFLOW.reduce((a, b) => a + b.income, 0);
const totalOut = CASHFLOW.reduce((a, b) => a + Math.abs(b.spend), 0);
const net = totalIn - totalOut;
return (
<div className="dash-card dash-card-tall">
<div className="dash-card-head">
<div>
<div className="dash-card-eyebrow">CASHFLOW · LAST 30 DAYS</div>
<div className="dash-card-title">Net <span className={net >= 0 ? "pos" : "neg"}>
{net >= 0 ? "+ " : " "}{Math.abs(net).toLocaleString()}
<span className="ccy"> KRW</span>
</span></div>
</div>
<div className="dash-card-legend">
<span className="lg-dot" style={{ background: "var(--conf-rules)" }}></span>
<span className="lg-lbl">INCOME</span>
<span className="lg-dot" style={{ background: "var(--fg-strong)", marginLeft: 12 }}></span>
<span className="lg-lbl">SPEND</span>
</div>
</div>
<div className="dash-chart-wrap">
<CashflowChart data={CASHFLOW} />
</div>
<div className="dash-card-foot">
<div className="dash-foot-cell">
<span className="dash-foot-lbl">INCOME</span>
<span className="dash-foot-val pos">+ {totalIn.toLocaleString()} <span className="ccy">KRW</span></span>
</div>
<div className="dash-foot-cell">
<span className="dash-foot-lbl">SPEND</span>
<span className="dash-foot-val"> {totalOut.toLocaleString()} <span className="ccy">KRW</span></span>
</div>
<div className="dash-foot-cell">
<span className="dash-foot-lbl">AVG. CONF.</span>
<span className="dash-foot-val">0.87</span>
</div>
<div className="dash-foot-cell">
<span className="dash-foot-lbl">RUNS</span>
<span className="dash-foot-val">12</span>
</div>
</div>
</div>
);
}
function ConfidenceCard() {
return (
<div className="dash-card dash-card-tall">
<div className="dash-card-head">
<div>
<div className="dash-card-eyebrow">CATEGORIZATION · BY TIER</div>
<div className="dash-card-title">Confidence mix</div>
</div>
</div>
<div className="dash-donut-wrap">
<Donut data={CONFIDENCE_DIST} />
<div className="dash-donut-legend">
{CONFIDENCE_DIST.map(d => {
const total = CONFIDENCE_DIST.reduce((a, b) => a + b.count, 0);
const pct = ((d.count / total) * 100).toFixed(0);
return (
<div key={d.tier} className="dash-legend-row">
<span className={`chip ${d.tier}`}><span className="val">{pct}%</span></span>
<span className="dash-legend-lbl">{d.label}</span>
<span className="dash-legend-num">{d.count}</span>
</div>
);
})}
</div>
</div>
<div className="dash-card-foot">
<div className="dash-foot-cell">
<span className="dash-foot-lbl">AUTO-POSTED</span>
<span className="dash-foot-val pos">86%</span>
</div>
<div className="dash-foot-cell">
<span className="dash-foot-lbl">NEED REVIEW</span>
<span className="dash-foot-val">64</span>
</div>
</div>
</div>
);
}
function CategoryCard() {
const max = Math.max(...CATEGORIES.map(c => c.amount));
const total = CATEGORIES.reduce((a, b) => a + b.amount, 0);
const entityColor = {
personal: "var(--entity-personal)",
tfox: "var(--entity-9tfox)",
finacode: "var(--entity-finacode)",
};
return (
<div className="dash-card">
<div className="dash-card-head">
<div>
<div className="dash-card-eyebrow">SPEND · BY CATEGORY · MAR 2026</div>
<div className="dash-card-title">
{total.toLocaleString()}<span className="ccy"> KRW</span>
</div>
</div>
<button className="dash-card-action">VIEW LEDGER </button>
</div>
<div className="dash-bars">
{CATEGORIES.map((c, i) => {
const w = (c.amount / max) * 100;
const pct = ((c.amount / total) * 100).toFixed(1);
return (
<div key={i} className="dash-bar-row">
<div className="dash-bar-lbl">{c.name}</div>
<div className="dash-bar-track">
<div className="dash-bar-fill"
style={{ width: `${w}%`, background: entityColor[c.entity] }}></div>
</div>
<div className="dash-bar-amt">
&nbsp;{c.amount.toLocaleString()}
<span className="ccy"> KRW</span>
</div>
<div className="dash-bar-pct">{pct}%</div>
</div>
);
})}
</div>
</div>
);
}
function EntitySplitCard() {
const entities = [
{ id: "personal", label: "Personal", income: 0, spend: 1205000, txCount: 38, color: "var(--entity-personal)" },
{ id: "tfox", label: "9TFox", income: 4800000, spend: 138800, txCount: 17, color: "var(--entity-9tfox)" },
{ id: "finacode", label: "Finacode", income: 1810000, spend: 0, txCount: 3, color: "var(--entity-finacode)" },
];
const total = entities.reduce((a, b) => a + b.income + b.spend, 0);
return (
<div className="dash-card">
<div className="dash-card-head">
<div>
<div className="dash-card-eyebrow">ENTITY · ACTIVITY MIX</div>
<div className="dash-card-title">3 entities · 58 transactions</div>
</div>
</div>
{/* Stacked bar across entities */}
<div className="dash-stacked">
{entities.map(e => {
const w = ((e.income + e.spend) / total) * 100;
return (
<div key={e.id} className="dash-stacked-seg"
style={{ width: `${w}%`, background: e.color }}
title={`${e.label} · ${e.txCount}`}>
<span className="dash-stacked-lbl">{e.label}</span>
</div>
);
})}
</div>
<div className="dash-entity-table">
<div className="dash-entity-head">
<span></span>
<span>INCOME</span>
<span>SPEND</span>
<span>NET</span>
<span>TX</span>
</div>
{entities.map(e => (
<div key={e.id} className="dash-entity-row">
<span className={`entity-badge ${e.id === "tfox" ? "tfox" : e.id}`}>{e.label}</span>
<span className="amount pos">{e.income ? `+ ${e.income.toLocaleString()}` : "—"}<span className="ccy"> KRW</span></span>
<span className="amount">{e.spend ? ` ${e.spend.toLocaleString()}` : "—"}<span className="ccy"> KRW</span></span>
<span className={`amount ${e.income - e.spend >= 0 ? "pos" : ""}`}>
{e.income - e.spend >= 0 ? "+ " : " "}
{Math.abs(e.income - e.spend).toLocaleString()}
<span className="ccy"> KRW</span>
</span>
<span className="dash-tx-count">{e.txCount}</span>
</div>
))}
</div>
</div>
);
}
function PipelineStatusCard() {
const lines = [
{ t: "09:14", src: "Toss · 신한 입출금", rows: 247, msg: "153 auto · 44 high · 35 review · 15 failed", status: "warn" },
{ t: "08:02", src: "Kakao Bank · 9TFox", rows: 62, msg: "58 auto · 3 high · 1 review", status: "ok" },
{ t: "07:48", src: "Wise · 9TFox", rows: 14, msg: "12 auto · 2 high", status: "ok" },
{ t: "06:01", src: "Garanti BBVA", rows: 89, msg: "61 auto · 14 high · 11 review · 3 failed", status: "warn" },
];
return (
<div className="dash-card">
<div className="dash-card-head">
<div>
<div className="dash-card-eyebrow">PIPELINE · LAST 24 HOURS</div>
<div className="dash-card-title">4 imports · 412 rows · 50 staged</div>
</div>
<button className="dash-card-action">VIEW ALL </button>
</div>
<div className="dash-log">
{lines.map((l, i) => (
<div key={i} className="dash-log-row">
<span className={`dash-log-dot dash-${l.status}`}></span>
<span className="dash-log-time">{l.t}</span>
<span className="dash-log-src">{l.src}</span>
<span className="dash-log-rows">{l.rows} rows</span>
<span className="dash-log-msg">{l.msg}</span>
</div>
))}
</div>
</div>
);
}
function DashboardScreen({ entity, onJump }) {
return (
<div className="dashboard-screen">
<div className="screen-head">
<div>
<div className="screen-eyebrow">OVERVIEW · MAR 2026 · {entity === "all" ? "ALL ENTITIES" : entity.toUpperCase()}</div>
<h1 className="screen-title">Ledger status</h1>
<div className="screen-sub">8 days into the period · 58 transactions · 64 staged for review · ledger clean on <span className="mono">main</span></div>
</div>
<div className="screen-actions">
<button className="btn" onClick={() => onJump && onJump("import")}>
<Icon name="import" size={13} /> Import CSV
</button>
<button className="btn primary" onClick={() => onJump && onJump("review")}>
REVIEW QUEUE 64
</button>
</div>
</div>
{/* KPI row */}
<div className="dash-stat-grid">
<StatCard
eyebrow="STAGED FOR REVIEW"
value="64"
delta="+ 12 since yesterday"
deltaDir="up"
spark={SPARK.staged}
sparkColor="var(--fg-muted)"
footnote="across 3 entities"
/>
<StatCard
eyebrow="SPEND · MAR 2026"
value="2,140,400"
unit="KRW"
delta=" 4.2% vs Feb"
deltaDir="down"
spark={SPARK.spend}
sparkColor="var(--fg-strong)"
footnote="excl. FX in transit"
/>
<StatCard
eyebrow="INCOME · MAR 2026"
value="6,610,000"
unit="KRW"
delta="+ 8.1% vs Feb"
deltaDir="up"
spark={SPARK.income}
sparkColor="var(--conf-rules)"
footnote="9TFox + Finacode"
/>
<StatCard
eyebrow="AVG. CONFIDENCE"
value="0.89"
delta="+ 0.04 since Feb"
deltaDir="up"
spark={SPARK.conf}
sparkColor="var(--conf-high)"
footnote="rules ramping up"
/>
</div>
{/* Main 2-col grid */}
<div className="dash-grid-main">
<CashflowCard />
<ConfidenceCard />
</div>
{/* Secondary 2-col grid */}
<div className="dash-grid-secondary">
<CategoryCard />
<EntitySplitCard />
</div>
{/* Pipeline */}
<PipelineStatusCard />
</div>
);
}
Object.assign(window, { DashboardScreen });
})();