diff --git a/ui_kits/web/App.jsx b/ui_kits/web/App.jsx index 6eb3a4c..d1ab5e8 100644 --- a/ui_kits/web/App.jsx +++ b/ui_kits/web/App.jsx @@ -2,7 +2,7 @@ function App() { const [entity, setEntity] = React.useState("all"); - const [screen, setScreen] = React.useState("review"); + const [screen, setScreen] = React.useState("dashboard"); const [query, setQuery] = React.useState(""); const [sort, setSort] = React.useState("date-desc"); const [transactions, setTransactions] = React.useState(window.AKEFIN_DATA.transactions); @@ -86,6 +86,7 @@ function App() { case "switch-personal": setEntity("personal"); break; case "switch-9tfox": setEntity("tfox"); break; case "switch-finacode": setEntity("finacode"); break; + case "go-dashboard": setScreen("dashboard"); break; case "go-review": setScreen("review"); break; case "go-rules": setScreen("rules"); break; case "go-ledger": setScreen("ledger"); break; @@ -128,6 +129,7 @@ function App() {
+ {screen === "dashboard" && } {screen === "review" && (
{ + // 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 ( + + {fill && } + + {/* end dot */} + + + ); + } + + function StatCard({ eyebrow, value, unit, delta, deltaDir, spark, sparkColor, footnote }) { + return ( +
+
{eyebrow}
+
+
+ {value} + {unit && {unit}} +
+ {spark && } +
+
+ {delta && ( + + {deltaDir === "up" ? "↑" : deltaDir === "down" ? "↓" : "·"} + {delta} + + )} + {footnote && {footnote}} +
+
+ ); + } + + // 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 ( + + {/* Gridlines */} + {ticks.map(t => ( + + + {fmtTick(t)} + + ))} + + {/* 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 ( + + {d.spend !== 0 && ( + + )} + {d.income !== 0 && ( + + )} + + ); + })} + + {/* 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 ( + + Feb {6 + d.day} + + ); + })} + + ); + } + + // 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 ( + + {/* track */} + + {segs.map((s, i) => ( + + ))} + {total} + CATEGORIZED · MAR + + ); + } + + // ---- 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 ( +
+
+
+
CASHFLOW · LAST 30 DAYS
+
Net = 0 ? "pos" : "neg"}> + {net >= 0 ? "+ " : "− "}{Math.abs(net).toLocaleString()} + KRW +
+
+
+ + INCOME + + SPEND +
+
+
+ +
+
+
+ INCOME + + {totalIn.toLocaleString()} KRW +
+
+ SPEND + − {totalOut.toLocaleString()} KRW +
+
+ AVG. CONF. + 0.87 +
+
+ RUNS + 12 +
+
+
+ ); + } + + function ConfidenceCard() { + return ( +
+
+
+
CATEGORIZATION · BY TIER
+
Confidence mix
+
+
+
+ +
+ {CONFIDENCE_DIST.map(d => { + const total = CONFIDENCE_DIST.reduce((a, b) => a + b.count, 0); + const pct = ((d.count / total) * 100).toFixed(0); + return ( +
+ {pct}% + {d.label} + {d.count} +
+ ); + })} +
+
+
+
+ AUTO-POSTED + 86% +
+
+ NEED REVIEW + 64 +
+
+
+ ); + } + + 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 ( +
+
+
+
SPEND · BY CATEGORY · MAR 2026
+
+ − {total.toLocaleString()} KRW +
+
+ +
+
+ {CATEGORIES.map((c, i) => { + const w = (c.amount / max) * 100; + const pct = ((c.amount / total) * 100).toFixed(1); + return ( +
+
{c.name}
+
+
+
+
+ − {c.amount.toLocaleString()} + KRW +
+
{pct}%
+
+ ); + })} +
+
+ ); + } + + 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 ( +
+
+
+
ENTITY · ACTIVITY MIX
+
3 entities · 58 transactions
+
+
+ {/* Stacked bar across entities */} +
+ {entities.map(e => { + const w = ((e.income + e.spend) / total) * 100; + return ( +
+ {e.label} +
+ ); + })} +
+
+
+ + INCOME + SPEND + NET + TX +
+ {entities.map(e => ( +
+ {e.label} + {e.income ? `+ ${e.income.toLocaleString()}` : "—"} KRW + {e.spend ? `− ${e.spend.toLocaleString()}` : "—"} KRW + = 0 ? "pos" : ""}`}> + {e.income - e.spend >= 0 ? "+ " : "− "} + {Math.abs(e.income - e.spend).toLocaleString()} + KRW + + {e.txCount} +
+ ))} +
+
+ ); + } + + 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 ( +
+
+
+
PIPELINE · LAST 24 HOURS
+
4 imports · 412 rows · 50 staged
+
+ +
+
+ {lines.map((l, i) => ( +
+ + {l.t} + {l.src} + {l.rows} rows + {l.msg} +
+ ))} +
+
+ ); + } + + function DashboardScreen({ entity, onJump }) { + return ( +
+
+
+
OVERVIEW · MAR 2026 · {entity === "all" ? "ALL ENTITIES" : entity.toUpperCase()}
+

Ledger status

+
8 days into the period · 58 transactions · 64 staged for review · ledger clean on main
+
+
+ + +
+
+ + {/* KPI row */} +
+ + + + +
+ + {/* Main 2-col grid */} +
+ + +
+ + {/* Secondary 2-col grid */} +
+ + +
+ + {/* Pipeline */} + +
+ ); + } + + Object.assign(window, { DashboardScreen }); +})(); diff --git a/ui_kits/web/dashboard.css b/ui_kits/web/dashboard.css index 03f9dd3..a501fa1 100644 --- a/ui_kits/web/dashboard.css +++ b/ui_kits/web/dashboard.css @@ -1575,3 +1575,397 @@ button { font-family: inherit; cursor: pointer; } background: var(--paper); } .entity-radio-item:hover { background: var(--paper); } + +/* ========================================================================= + DASHBOARD / OVERVIEW SCREEN + ========================================================================= */ +.dashboard-screen { + padding: 24px; + overflow-y: auto; + height: 100%; +} +.screen-eyebrow { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.16em; + color: var(--fg-subtle); + text-transform: uppercase; + margin-bottom: 6px; +} + +/* ---- KPI row -------------------------------------------------------- */ +.dash-stat-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + margin-bottom: 16px; +} +.dash-stat { + background: var(--paper); + border: 1px solid var(--rule); + border-radius: var(--r-md); + padding: 14px 16px 12px; + display: flex; flex-direction: column; + gap: 10px; + min-height: 116px; +} +.dash-stat-eyebrow { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.16em; + color: var(--fg-subtle); + text-transform: uppercase; +} +.dash-stat-value-row { + display: flex; align-items: flex-end; justify-content: space-between; + gap: 8px; +} +.dash-stat-value { + font-family: var(--font-mono); + font-size: 26px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--fg-strong); + line-height: 1; + letter-spacing: -0.01em; +} +.dash-stat-unit { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 400; + color: var(--fg-subtle); + margin-left: 4px; + letter-spacing: 0.04em; +} +.dash-stat-foot { + display: flex; align-items: center; gap: 10px; + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: 0.04em; + color: var(--fg-muted); + margin-top: auto; + padding-top: 4px; + border-top: 1px dashed var(--rule); +} +.dash-delta { + display: inline-flex; align-items: center; gap: 3px; + font-variant-numeric: tabular-nums; +} +.dash-delta.up { color: var(--conf-rules); } +.dash-delta.down { color: var(--conf-low); } +.dash-delta.flat { color: var(--fg-muted); } +.dash-delta .arrow { font-weight: 600; } +.dash-stat-foot-note { + color: var(--fg-subtle); + margin-left: auto; + font-size: 10px; + letter-spacing: 0.04em; +} + +.spark { display: block; } + +/* ---- Generic dash card --------------------------------------------- */ +.dash-card { + background: var(--paper); + border: 1px solid var(--rule); + border-radius: var(--r-md); + padding: 16px 20px; + display: flex; flex-direction: column; + gap: 14px; +} +.dash-card-tall { min-height: 360px; } + +.dash-card-head { + display: flex; align-items: flex-start; justify-content: space-between; + gap: 16px; +} +.dash-card-eyebrow { + font-family: var(--font-mono); + font-size: 9.5px; + font-weight: 500; + letter-spacing: 0.16em; + color: var(--fg-subtle); + text-transform: uppercase; + margin-bottom: 4px; +} +.dash-card-title { + font-family: var(--font-sans); + font-size: 18px; + font-weight: 500; + color: var(--fg-strong); + letter-spacing: -0.005em; +} +.dash-card-title .pos { color: var(--conf-rules); font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.dash-card-title .neg { color: var(--fg-strong); font-family: var(--font-mono); font-variant-numeric: tabular-nums; } +.dash-card-title .ccy { color: var(--fg-subtle); font-weight: 400; font-size: 12px; } +.dash-card-title .ccy:not(:first-child) { margin-left: 2px; } +.dash-card-legend { + display: flex; align-items: center; gap: 6px; + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.16em; + color: var(--fg-subtle); +} +.dash-card-legend .lg-dot { + display: inline-block; + width: 8px; height: 8px; + border-radius: 1px; + margin-right: 2px; +} +.dash-card-action { + background: transparent; + border: none; + padding: 0; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.14em; + color: var(--fg-muted); +} +.dash-card-action:hover { color: var(--fg-strong); } + +/* ---- Cashflow chart ------------------------------------------------- */ +.dash-chart-wrap { + width: 100%; + flex: 1; + min-height: 200px; +} +.chart-svg { + width: 100%; + height: 240px; + display: block; + overflow: visible; +} +.axis-label { + font-family: var(--font-mono); + font-size: 9.5px; + fill: var(--fg-subtle); + letter-spacing: 0.06em; +} + +.dash-card-foot { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--rule); +} +.dash-foot-cell { display: flex; flex-direction: column; gap: 4px; } +.dash-foot-lbl { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.16em; + color: var(--fg-subtle); +} +.dash-foot-val { + font-family: var(--font-mono); + font-size: 14px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--fg-strong); +} +.dash-foot-val.pos { color: var(--conf-rules); } +.dash-foot-val .ccy { color: var(--fg-subtle); font-weight: 400; font-size: 11px; margin-left: 2px; } + +/* ---- Donut ---------------------------------------------------------- */ +.dash-donut-wrap { + display: flex; align-items: center; gap: 24px; + flex: 1; + padding: 8px 0; +} +.donut { flex-shrink: 0; } +.donut-num { + font-family: var(--font-mono); + font-size: 24px; + font-weight: 500; + fill: var(--fg-strong); + font-variant-numeric: tabular-nums; +} +.donut-lbl { + font-family: var(--font-mono); + font-size: 8px; + letter-spacing: 0.18em; + fill: var(--fg-subtle); +} +.dash-donut-legend { + flex: 1; + display: flex; flex-direction: column; + gap: 8px; +} +.dash-legend-row { + display: grid; + grid-template-columns: 56px 1fr auto; + align-items: center; + gap: 12px; + padding: 4px 0; + border-bottom: 1px dashed var(--rule); +} +.dash-legend-row:last-child { border-bottom: none; } +.dash-legend-lbl { + font-family: var(--font-sans); + font-size: 12.5px; + color: var(--fg); +} +.dash-legend-num { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 500; + font-variant-numeric: tabular-nums; + color: var(--fg-strong); +} + +/* ---- Two-column grids ---------------------------------------------- */ +.dash-grid-main { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(0, 1fr); + gap: 12px; + margin-bottom: 16px; +} +.dash-grid-secondary { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +/* ---- Category bars -------------------------------------------------- */ +.dash-bars { + display: flex; flex-direction: column; + gap: 2px; +} +.dash-bar-row { + display: grid; + grid-template-columns: 140px 1fr 140px 44px; + align-items: center; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid var(--rule); +} +.dash-bar-row:last-child { border-bottom: none; } +.dash-bar-lbl { + font-family: var(--font-sans); + font-size: 12.5px; + color: var(--fg); +} +.dash-bar-track { + position: relative; + height: 8px; + background: var(--bg); + border: 1px solid var(--rule); + border-radius: 1px; + overflow: hidden; +} +.dash-bar-fill { + height: 100%; + background: var(--fg-strong); + transition: width var(--dur-slow) var(--ease); +} +.dash-bar-amt { + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--fg); + text-align: right; +} +.dash-bar-amt .ccy { color: var(--fg-subtle); } +.dash-bar-pct { + font-family: var(--font-mono); + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--fg-subtle); + text-align: right; + letter-spacing: 0.04em; +} + +/* ---- Entity stacked --------------------------------------------- */ +.dash-stacked { + display: flex; + height: 28px; + width: 100%; + border: 1px solid var(--rule); + border-radius: var(--r-sm); + overflow: hidden; +} +.dash-stacked-seg { + display: flex; align-items: center; justify-content: center; + font-family: var(--font-mono); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.14em; + color: var(--bg); + text-transform: uppercase; + white-space: nowrap; + overflow: hidden; + border-right: 1px solid rgba(255,255,255,0.18); +} +.dash-stacked-seg:last-child { border-right: none; } +.dash-stacked-lbl { padding: 0 8px; } + +.dash-entity-table { + display: flex; flex-direction: column; + gap: 0; + margin-top: 4px; +} +.dash-entity-head, +.dash-entity-row { + display: grid; + grid-template-columns: 110px 1fr 1fr 1fr 50px; + gap: 12px; + align-items: center; + padding: 8px 0; +} +.dash-entity-head { + font-family: var(--font-mono); + font-size: 9.5px; + letter-spacing: 0.16em; + color: var(--fg-subtle); + border-bottom: 1px solid var(--rule); + padding-bottom: 6px; +} +.dash-entity-head > span:not(:first-child) { text-align: right; } +.dash-entity-row { + border-bottom: 1px solid var(--rule); +} +.dash-entity-row:last-child { border-bottom: none; } +.dash-entity-row .amount { text-align: right; } +.dash-entity-row .amount.pos { color: var(--conf-rules); } +.dash-tx-count { + font-family: var(--font-mono); + font-size: 13px; + font-variant-numeric: tabular-nums; + text-align: right; + color: var(--fg-strong); +} + +/* ---- Pipeline log card --------------------------------------------- */ +.dash-log { + background: var(--surface); + border: 1px solid var(--rule-ink); + border-radius: var(--r-sm); + font-family: var(--font-mono); + font-size: 12px; + color: var(--fg-on-ink); + padding: 4px 0; +} +.dash-log-row { + display: grid; + grid-template-columns: 14px 56px 220px 84px 1fr; + align-items: center; + gap: 12px; + padding: 8px 16px; + border-bottom: 1px dashed var(--rule-ink); +} +.dash-log-row:last-child { border-bottom: none; } +.dash-log-dot { + width: 7px; height: 7px; + border-radius: 50%; +} +.dash-log-dot.dash-ok { background: var(--conf-rules); } +.dash-log-dot.dash-warn { background: var(--conf-mid); } +.dash-log-dot.dash-fail { background: var(--conf-low); } +.dash-log-time { color: var(--fg-on-ink-muted); letter-spacing: 0.06em; } +.dash-log-src { color: var(--fg-on-ink); font-family: var(--font-sans-kr); } +.dash-log-rows { color: var(--fg-on-ink-muted); font-variant-numeric: tabular-nums; } +.dash-log-msg { color: var(--fg-on-ink-muted); } + diff --git a/ui_kits/web/index.html b/ui_kits/web/index.html index 1df8286..644f719 100644 --- a/ui_kits/web/index.html +++ b/ui_kits/web/index.html @@ -24,6 +24,7 @@ +