feat: dashboard is added
This commit is contained in:
parent
8b790b7601
commit
09fc5dc367
5 changed files with 892 additions and 1 deletions
|
|
@ -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() {
|
|||
<div className="ak-body">
|
||||
<Sidebar screen={screen} setScreen={setScreen} counts={counts} />
|
||||
<main className="ak-main">
|
||||
{screen === "dashboard" && <DashboardScreen entity={entity} onJump={setScreen} />}
|
||||
{screen === "review" && (
|
||||
<div className="review-screen">
|
||||
<ReviewQueue entity={entity} transactions={transactions}
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ function Header({ entity, onEntityChange, query, setQuery, statusLine, onOpenPal
|
|||
|
||||
function Sidebar({ screen, setScreen, counts }) {
|
||||
const items = [
|
||||
{ id: "dashboard", label: "Overview", icon: "spark", count: null },
|
||||
{ id: "review", label: "Review queue", icon: "activity", count: counts.review },
|
||||
{ id: "rules", label: "Rules", icon: "rule", count: counts.rules },
|
||||
{ id: "ledger", label: "Ledger", icon: "ledger", count: null },
|
||||
|
|
|
|||
493
ui_kits/web/DashboardScreen.jsx
Normal file
493
ui_kits/web/DashboardScreen.jsx
Normal file
|
|
@ -0,0 +1,493 @@
|
|||
// 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 });
|
||||
})();
|
||||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
<script type="text/babel" src="RulesScreen.jsx"></script>
|
||||
<script type="text/babel" src="LedgerScreen.jsx"></script>
|
||||
<script type="text/babel" src="ImportScreen.jsx"></script>
|
||||
<script type="text/babel" src="DashboardScreen.jsx"></script>
|
||||
<script type="text/babel" src="App.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue