493 lines
20 KiB
JavaScript
493 lines
20 KiB
JavaScript
// 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.70–0.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">
|
||
− {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 });
|
||
})();
|