feat: accounts and history pages are in place

This commit is contained in:
Alkim Ake Gozen 2026-05-23 23:43:08 +09:00
parent 09fc5dc367
commit 8c42d246ea
7 changed files with 1135 additions and 3 deletions

View file

@ -0,0 +1,244 @@
// AccountsScreen connected data sources + chart of accounts
(function () {
const fmt = (n, ccy) => {
if (n == null || isNaN(n)) return "—";
const abs = Math.abs(n);
const isInt = ccy === "KRW" || ccy === "JPY" || Math.abs(abs - Math.round(abs)) < 0.005;
return (n < 0 ? " " : "") + abs.toLocaleString(undefined, {
minimumFractionDigits: isInt ? 0 : 2,
maximumFractionDigits: isInt ? 0 : 2,
});
};
const entityShort = { personal: "P/", tfox: "9T/", finacode: "FC/" };
const entityClass = { personal: "personal", tfox: "tfox", finacode: "finacode" };
// ----- Status pill --------------------------------------------------
function StatusPill({ status }) {
const map = {
ok: { lbl: "OK", cls: "ok" },
warn: { lbl: "WARN", cls: "warn" },
fail: { lbl: "FAILED", cls: "fail" },
idle: { lbl: "IDLE", cls: "idle" },
};
const m = map[status] || map.idle;
return <span className={`acct-pill acct-pill-${m.cls}`}><span className="acct-pill-dot"></span>{m.lbl}</span>;
}
function MethodTag({ method }) {
const map = {
"api": "API",
"csv-poll": "CSV · POLL",
"csv-drop": "CSV · DROP",
"manual": "MANUAL",
};
return <span className="acct-method">{map[method] || method.toUpperCase()}</span>;
}
// ----- Connected source card ---------------------------------------
function SourceCard({ src, selected, onClick }) {
return (
<button className={`acct-src-card ${selected ? "selected" : ""}`} onClick={onClick}>
<div className="acct-src-head">
<span className={`entity-badge ${entityClass[src.entity]}`}>{entityShort[src.entity]}</span>
<StatusPill status={src.status} />
</div>
<div className="acct-src-name">{src.name}</div>
<div className="acct-src-meta">
<span className="acct-src-no">{src.maskedNo}</span>
<span className="acct-src-country">· {src.country}</span>
</div>
<div className="acct-src-balance">
<span className="acct-src-balance-val">{fmt(src.balance, src.ccy)}</span>
<span className="ccy"> {src.ccy}</span>
</div>
<div className="acct-src-foot">
<MethodTag method={src.method} />
<span className="acct-src-sync">SYNC&nbsp;{src.lastSync.split(" ")[1]}</span>
</div>
</button>
);
}
// ----- Chart of accounts tree --------------------------------------
// accountsByEntity rows have full path; we render them grouped per entity, indented by depth.
function ChartOfAccounts({ entity }) {
const data = window.AKEFIN_DATA.accountsByEntity;
const sets = entity === "all"
? [["Personal", data.personal, "personal"], ["9TFox", data.tfox, "tfox"], ["Finacode", data.finacode, "finacode"]]
: [[entity === "tfox" ? "9TFox" : entity[0].toUpperCase() + entity.slice(1), data[entity], entity]];
return (
<div className="acct-coa">
{sets.map(([title, rows, cls]) => (
<div key={title} className="acct-coa-group">
<div className="acct-coa-head">
<span className={`entity-badge ${entityClass[cls]}`}>{entityShort[cls]}</span>
<span className="acct-coa-title">{title}</span>
<span className="acct-coa-count">{rows.length} accounts</span>
</div>
<div className="acct-coa-rows">
{rows.map((r) => {
const depth = (r.path.match(/:/g) || []).length;
const leaf = r.path.split(":").pop();
return (
<div key={r.path} className={`acct-coa-row ${r.kind}`}>
<span className="acct-coa-tree" style={{ paddingLeft: depth * 18 }}>
<span className="acct-coa-glyph">{r.kind === "branch" ? "▸" : "·"}</span>
<span className="acct-coa-leaf">{leaf}</span>
</span>
<span className="acct-coa-path mono">{r.path}</span>
<span className={`acct-coa-amt mono ${r.balance < 0 ? "neg" : "pos"}`}>
{r.balance >= 0 ? "+ " : " "}{fmt(Math.abs(r.balance), r.ccy)}
<span className="ccy"> {r.ccy}</span>
</span>
</div>
);
})}
</div>
</div>
))}
</div>
);
}
// ----- Detail panel for a selected source --------------------------
function SourceDetail({ src }) {
if (!src) {
return (
<div className="acct-detail acct-detail-empty">
<div className="acct-detail-empty-lbl">SELECT A SOURCE</div>
<div className="acct-detail-empty-sub">Click a connected account to inspect sync settings, recent imports, and rotation.</div>
</div>
);
}
return (
<div className="acct-detail">
<div className="acct-detail-head">
<div>
<div className="acct-detail-eyebrow">{src.kind.toUpperCase()} · {src.country}</div>
<h2 className="acct-detail-title">{src.name}</h2>
<div className="acct-detail-sub mono">{src.maskedNo}</div>
</div>
<StatusPill status={src.status} />
</div>
<div className="acct-detail-stat-grid">
<div className="acct-detail-stat">
<div className="lbl">BALANCE</div>
<div className="val">{fmt(src.balance, src.ccy)}<span className="ccy"> {src.ccy}</span></div>
</div>
<div className="acct-detail-stat">
<div className="lbl">TX · 30D</div>
<div className="val">{src.txCount30d}</div>
</div>
<div className="acct-detail-stat">
<div className="lbl">LAST SYNC</div>
<div className="val">{src.lastSync.split(" ")[1]}</div>
<div className="sub">{src.lastSync.split(" ")[0]}</div>
</div>
<div className="acct-detail-stat">
<div className="lbl">NEXT</div>
<div className="val">{src.nextRun}</div>
</div>
</div>
<div className="acct-detail-section">
<div className="acct-detail-section-head">SYNC</div>
<div className="acct-kv-row"><span className="k">METHOD</span><span className="v"><MethodTag method={src.method} /></span></div>
<div className="acct-kv-row"><span className="k">ENTITY</span><span className="v"><span className={`entity-badge ${entityClass[src.entity]}`}>{entityShort[src.entity]}</span>&nbsp;{src.entity === "tfox" ? "9TFox" : src.entity[0].toUpperCase() + src.entity.slice(1)}</span></div>
<div className="acct-kv-row"><span className="k">SCHEDULE</span><span className="v mono">{src.method === "api" ? "every 4 hours" : src.method === "csv-poll" ? "every 12 hours" : "on demand"}</span></div>
<div className="acct-kv-row"><span className="k">CCY</span><span className="v mono">{src.ccy}</span></div>
</div>
<div className="acct-detail-section">
<div className="acct-detail-section-head">RECENT IMPORTS</div>
<div className="acct-mini-log">
<div className="acct-mini-row">
<span className="t">{src.lastSync.split(" ")[1]}</span>
<span className="msg">Last successful sync · {src.txCount30d} rows in 30d</span>
</div>
{src.status === "fail" && (
<div className="acct-mini-row fail">
<span className="t">{src.lastSync.split(" ")[1]}</span>
<span className="msg">CSV parse failed · encoding mismatch</span>
</div>
)}
{src.status === "warn" && (
<div className="acct-mini-row warn">
<span className="t">{src.lastSync.split(" ")[1]}</span>
<span className="msg">11 rows queued for review · 3 failed</span>
</div>
)}
</div>
</div>
<div className="acct-detail-actions">
<button className="btn"><Icon name="refresh" size={13} /> Sync now</button>
<button className="btn">Rotate credentials</button>
<button className="btn ghost">Disconnect</button>
</div>
</div>
);
}
// ----- Top-level screen --------------------------------------------
function AccountsScreen({ entity }) {
const [tab, setTab] = React.useState("sources"); // sources | coa
const sources = window.AKEFIN_DATA.connectedAccounts;
const visible = sources.filter(s => entity === "all" || s.entity === entity);
const [selectedId, setSelectedId] = React.useState(visible[0]?.id);
React.useEffect(() => {
if (visible.length && !visible.find(s => s.id === selectedId)) {
setSelectedId(visible[0].id);
}
}, [entity]);
const selected = visible.find(s => s.id === selectedId);
// Totals (in KRW equivalent)
const totalKrw = visible.reduce((a, s) => a + window.AKEFIN_DATA.convert(s.balance, s.ccy, "KRW"), 0);
const okCount = visible.filter(s => s.status === "ok").length;
const warnCount = visible.filter(s => s.status === "warn" || s.status === "fail").length;
return (
<div className="accounts-screen">
<div className="screen-head">
<div>
<div className="screen-eyebrow">ACCOUNTS · {entity === "all" ? "ALL ENTITIES" : entity.toUpperCase()}</div>
<h1 className="screen-title">Connected sources</h1>
<div className="screen-sub">{visible.length} sources · {okCount} healthy · {warnCount} need attention · combined {fmt(totalKrw, "KRW")}<span className="ccy"> KRW</span></div>
</div>
<div className="screen-actions">
<button className="btn"><Icon name="refresh" size={13} /> Sync all</button>
<button className="btn primary"><Icon name="import" size={13} /> Connect source</button>
</div>
</div>
<div className="acct-tabs">
<button className={`acct-tab ${tab === "sources" ? "active" : ""}`} onClick={() => setTab("sources")}>
CONNECTED SOURCES <span className="acct-tab-count">{visible.length}</span>
</button>
<button className={`acct-tab ${tab === "coa" ? "active" : ""}`} onClick={() => setTab("coa")}>
CHART OF ACCOUNTS
</button>
</div>
{tab === "sources" && (
<div className="acct-layout">
<div className="acct-src-grid">
{visible.map(s => (
<SourceCard key={s.id} src={s} selected={s.id === selectedId} onClick={() => setSelectedId(s.id)} />
))}
</div>
<SourceDetail src={selected} />
</div>
)}
{tab === "coa" && <ChartOfAccounts entity={entity} />}
</div>
);
}
Object.assign(window, { AccountsScreen });
})();

View file

@ -91,6 +91,8 @@ function App() {
case "go-rules": setScreen("rules"); break;
case "go-ledger": setScreen("ledger"); break;
case "go-import": setScreen("import"); break;
case "go-accounts": setScreen("accounts"); break;
case "go-history": setScreen("history"); break;
case "toggle-theme": setTheme(t => t === "light" ? "dark" : "light"); break;
default: showToast(`${cmd.label}`);
}
@ -143,6 +145,8 @@ function App() {
{screen === "rules" && <RulesScreen entity={entity} />}
{screen === "ledger" && <LedgerScreen entity={entity} />}
{screen === "import" && <ImportScreen entity={entity} onOpenImport={() => setCsvOpen(true)} />}
{screen === "accounts" && <AccountsScreen entity={entity} />}
{screen === "history" && <HistoryScreen entity={entity} />}
</main>
</div>
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} onRun={runCommand} />

View file

@ -85,8 +85,16 @@ function Sidebar({ screen, setScreen, counts }) {
</button>
))}
<div className="ak-sb-section" style={{ marginTop: 16 }}>SETTINGS</div>
<button className="ak-sb-item"><Icon name="card" size={16} /><span className="ak-sb-label">Accounts</span></button>
<button className="ak-sb-item"><Icon name="clock" size={16} /><span className="ak-sb-label">History</span></button>
<button
className={`ak-sb-item ${screen === "accounts" ? "active" : ""}`}
onClick={() => setScreen("accounts")}>
<Icon name="card" size={16} /><span className="ak-sb-label">Accounts</span>
</button>
<button
className={`ak-sb-item ${screen === "history" ? "active" : ""}`}
onClick={() => setScreen("history")}>
<Icon name="clock" size={16} /><span className="ak-sb-label">History</span>
</button>
<div style={{ flex: 1 }}></div>
<div className="ak-sb-foot">
<div className="ak-sb-foot-row">

View file

@ -0,0 +1,245 @@
// HistoryScreen append-only audit log
(function () {
const KIND_META = {
import: { label: "IMPORT", glyph: "↓", color: "var(--fg-strong)" },
poll: { label: "POLL", glyph: "↻", color: "var(--fg-muted)" },
approve: { label: "APPROVE", glyph: "✓", color: "var(--conf-rules)" },
override: { label: "OVERRIDE", glyph: "✎", color: "var(--conf-high)" },
skip: { label: "SKIP", glyph: "↩", color: "var(--conf-mid)" },
rule: { label: "RULE", glyph: "§", color: "var(--conf-high)" },
commit: { label: "COMMIT", glyph: "●", color: "var(--fg-strong)" },
fx: { label: "FX", glyph: "◇", color: "var(--fg-muted)" },
categorize: { label: "AGENT", glyph: "△", color: "var(--conf-high)" },
error: { label: "ERROR", glyph: "✕", color: "var(--conf-low)" },
};
const ACTOR_LABEL = { you: "you", system: "system", agent: "agent" };
const entityShort = { all: "ALL", personal: "P/", tfox: "9T/", finacode: "FC/" };
const entityClass = { personal: "personal", tfox: "tfox", finacode: "finacode" };
function HistoryFilters({ activeKinds, toggleKind, actor, setActor, range, setRange }) {
const kinds = Object.keys(KIND_META);
return (
<div className="hist-filters">
<div className="hist-filter-row">
<span className="hist-filter-lbl">KIND</span>
<div className="hist-filter-chips">
<button className={`hist-chip ${activeKinds.size === 0 ? "active" : ""}`}
onClick={() => activeKinds.size > 0 && [...activeKinds].forEach(toggleKind)}>
ALL
</button>
{kinds.map(k => (
<button key={k}
className={`hist-chip ${activeKinds.has(k) ? "active" : ""}`}
onClick={() => toggleKind(k)}>
<span className="hist-chip-glyph" style={{ color: KIND_META[k].color }}>{KIND_META[k].glyph}</span>
{KIND_META[k].label}
</button>
))}
</div>
</div>
<div className="hist-filter-row">
<span className="hist-filter-lbl">ACTOR</span>
<div className="hist-filter-chips">
{["all", "you", "system", "agent"].map(a => (
<button key={a}
className={`hist-chip ${actor === a ? "active" : ""}`}
onClick={() => setActor(a)}>
{a.toUpperCase()}
</button>
))}
</div>
<span className="hist-filter-lbl" style={{ marginLeft: 24 }}>RANGE</span>
<div className="hist-filter-chips">
{[
{ id: "24h", label: "24H" },
{ id: "7d", label: "7D" },
{ id: "30d", label: "30D" },
{ id: "all", label: "ALL" },
].map(r => (
<button key={r.id}
className={`hist-chip ${range === r.id ? "active" : ""}`}
onClick={() => setRange(r.id)}>
{r.label}
</button>
))}
</div>
</div>
</div>
);
}
// Group log entries by date
function groupByDay(rows) {
const groups = new Map();
rows.forEach(r => {
const day = r.at.split(" ")[0];
if (!groups.has(day)) groups.set(day, []);
groups.get(day).push(r);
});
return [...groups.entries()];
}
function dayLabel(iso) {
const d = new Date(iso + "T00:00:00");
const today = new Date("2026-03-08T00:00:00");
const diff = Math.round((today - d) / (24 * 3600 * 1000));
if (diff === 0) return "TODAY";
if (diff === 1) return "YESTERDAY";
const wd = d.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase();
const md = d.toLocaleDateString("en-US", { month: "short", day: "numeric" }).toUpperCase();
return `${wd} · ${md}`;
}
function HistoryRow({ row }) {
const meta = KIND_META[row.kind];
return (
<div className="hist-row">
<div className="hist-col-time">
<span className="hist-time mono">{row.at.split(" ")[1]}</span>
</div>
<div className="hist-col-kind">
<span className="hist-glyph" style={{ color: meta.color }}>{meta.glyph}</span>
<span className="hist-kind-lbl">{meta.label}</span>
</div>
<div className="hist-col-actor">
<span className={`hist-actor hist-actor-${row.actor}`}>{ACTOR_LABEL[row.actor]}</span>
</div>
<div className="hist-col-entity">
<span className={`entity-badge ${entityClass[row.entity] || "all"}`}>{entityShort[row.entity]}</span>
</div>
<div className="hist-col-summary">
<div className="hist-summary">{row.summary}</div>
<div className="hist-detail mono">{row.detail}</div>
</div>
<div className="hist-col-ref mono">{row.ref}</div>
</div>
);
}
function HistoryScreen({ entity }) {
const all = window.AKEFIN_DATA.history;
const [activeKinds, setActiveKinds] = React.useState(new Set());
const [actor, setActor] = React.useState("all");
const [range, setRange] = React.useState("7d");
const toggleKind = (k) => {
setActiveKinds(prev => {
const next = new Set(prev);
if (next.has(k)) next.delete(k); else next.add(k);
return next;
});
};
// Range filter (anchored to 2026-03-08 the demo's "today")
const TODAY = new Date("2026-03-08T23:59:59");
const RANGE_DAYS = { "24h": 1, "7d": 7, "30d": 30, "all": 99999 };
const cutoff = new Date(TODAY.getTime() - RANGE_DAYS[range] * 24 * 3600 * 1000);
const rows = all.filter(r => {
if (entity !== "all" && r.entity !== entity && r.entity !== "all") return false;
if (activeKinds.size > 0 && !activeKinds.has(r.kind)) return false;
if (actor !== "all" && r.actor !== actor) return false;
const d = new Date(r.at.replace(" ", "T"));
if (d < cutoff) return false;
return true;
});
// Aggregates for header strip
const counts = rows.reduce((acc, r) => {
acc.total++;
acc.byActor[r.actor] = (acc.byActor[r.actor] || 0) + 1;
acc.byKind[r.kind] = (acc.byKind[r.kind] || 0) + 1;
return acc;
}, { total: 0, byActor: {}, byKind: {} });
const grouped = groupByDay(rows);
return (
<div className="history-screen">
<div className="screen-head">
<div>
<div className="screen-eyebrow">HISTORY · {entity === "all" ? "ALL ENTITIES" : entity.toUpperCase()}</div>
<h1 className="screen-title">Audit log</h1>
<div className="screen-sub">
{counts.total} events · append-only · backed by git on <span className="mono">main</span>
</div>
</div>
<div className="screen-actions">
<button className="btn">Export log</button>
<button className="btn">Diff vs. last commit</button>
</div>
</div>
{/* Summary strip */}
<div className="hist-summary-strip">
<div className="hist-summary-cell">
<div className="lbl">EVENTS</div>
<div className="val">{counts.total}</div>
</div>
<div className="hist-summary-cell">
<div className="lbl">BY YOU</div>
<div className="val">{counts.byActor.you || 0}</div>
</div>
<div className="hist-summary-cell">
<div className="lbl">BY SYSTEM</div>
<div className="val">{counts.byActor.system || 0}</div>
</div>
<div className="hist-summary-cell">
<div className="lbl">BY AGENT</div>
<div className="val">{counts.byActor.agent || 0}</div>
</div>
<div className="hist-summary-cell">
<div className="lbl">COMMITS</div>
<div className="val">{counts.byKind.commit || 0}</div>
</div>
<div className="hist-summary-cell">
<div className="lbl">ERRORS</div>
<div className={`val ${(counts.byKind.error || 0) > 0 ? "neg" : ""}`}>{counts.byKind.error || 0}</div>
</div>
</div>
<HistoryFilters
activeKinds={activeKinds}
toggleKind={toggleKind}
actor={actor} setActor={setActor}
range={range} setRange={setRange}
/>
<div className="hist-table">
<div className="hist-table-head">
<div className="hist-col-time">TIME</div>
<div className="hist-col-kind">KIND</div>
<div className="hist-col-actor">ACTOR</div>
<div className="hist-col-entity">ENT</div>
<div className="hist-col-summary">EVENT</div>
<div className="hist-col-ref">REF</div>
</div>
{grouped.length === 0 && (
<div className="hist-empty">
<div className="hist-empty-lbl">NO EVENTS MATCH</div>
<div className="hist-empty-sub">Try widening the range or clearing filters.</div>
</div>
)}
{grouped.map(([day, dayRows]) => (
<div key={day} className="hist-day">
<div className="hist-day-head">
<span className="hist-day-lbl">{dayLabel(day)}</span>
<span className="hist-day-date mono">{day}</span>
<span className="hist-day-count">{dayRows.length} events</span>
<span className="hist-day-rule"></span>
</div>
<div className="hist-day-rows">
{dayRows.map(r => <HistoryRow key={r.id} row={r} />)}
</div>
</div>
))}
</div>
</div>
);
}
Object.assign(window, { HistoryScreen });
})();

View file

@ -1969,3 +1969,601 @@ button { font-family: inherit; cursor: pointer; }
.dash-log-rows { color: var(--fg-on-ink-muted); font-variant-numeric: tabular-nums; }
.dash-log-msg { color: var(--fg-on-ink-muted); }
/* =========================================================================
ACCOUNTS SCREEN
========================================================================= */
.accounts-screen {
padding: 24px;
overflow-y: auto;
height: 100%;
}
.acct-tabs {
display: flex; gap: 0;
border-bottom: 1px solid var(--rule);
margin-bottom: 20px;
margin-top: 4px;
}
.acct-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
padding: 10px 16px 10px 0;
margin-right: 24px;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.16em;
color: var(--fg-muted);
display: inline-flex; align-items: center; gap: 8px;
}
.acct-tab.active { color: var(--fg-strong); border-bottom-color: var(--fg-strong); }
.acct-tab:hover { color: var(--fg-strong); }
.acct-tab-count {
background: var(--rule);
color: var(--fg-strong);
font-size: 10px;
letter-spacing: 0.04em;
padding: 1px 6px;
border-radius: 999px;
}
/* ---- Layout: cards grid + detail ----------------------------------- */
.acct-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 360px;
gap: 16px;
align-items: start;
}
.acct-src-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
/* ---- Source card --------------------------------------------------- */
.acct-src-card {
text-align: left;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
padding: 14px 16px;
display: flex; flex-direction: column;
gap: 8px;
position: relative;
transition: border-color var(--dur-fast) var(--ease), background var(--dur-fast) var(--ease);
}
.acct-src-card:hover {
border-color: var(--fg-muted);
}
.acct-src-card.selected {
border-color: var(--fg-strong);
background: color-mix(in srgb, var(--paper) 96%, var(--fg-strong) 4%);
}
.acct-src-card.selected::before {
content: "";
position: absolute;
left: -1px; top: -1px; bottom: -1px;
width: 3px;
background: var(--fg-strong);
border-radius: 1px 0 0 1px;
}
.acct-src-head {
display: flex; align-items: center; justify-content: space-between;
}
.acct-src-name {
font-family: var(--font-sans-kr);
font-size: 14.5px;
font-weight: 500;
color: var(--fg-strong);
}
.acct-src-meta {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.04em;
color: var(--fg-subtle);
display: flex; gap: 6px;
}
.acct-src-balance {
font-family: var(--font-mono);
font-size: 22px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--fg-strong);
letter-spacing: -0.01em;
margin-top: 2px;
}
.acct-src-balance .ccy {
color: var(--fg-subtle);
font-size: 12px;
font-weight: 400;
}
.acct-src-foot {
display: flex; align-items: center; justify-content: space-between;
padding-top: 8px;
border-top: 1px dashed var(--rule);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg-subtle);
}
.acct-src-sync { font-variant-numeric: tabular-nums; }
.acct-method {
display: inline-block;
padding: 2px 6px;
border: 1px solid var(--rule);
border-radius: 2px;
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: var(--fg-muted);
}
/* ---- Status pill --------------------------------------------------- */
.acct-pill {
display: inline-flex; align-items: center; gap: 5px;
height: 18px;
padding: 0 7px;
border-radius: 2px;
font-family: var(--font-mono);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.16em;
}
.acct-pill-dot {
width: 5px; height: 5px;
border-radius: 50%;
}
.acct-pill-ok { background: color-mix(in srgb, var(--conf-rules) 14%, transparent); color: var(--conf-rules); }
.acct-pill-ok .acct-pill-dot { background: var(--conf-rules); }
.acct-pill-warn { background: color-mix(in srgb, var(--conf-mid) 16%, transparent); color: var(--conf-mid); }
.acct-pill-warn .acct-pill-dot { background: var(--conf-mid); }
.acct-pill-fail { background: color-mix(in srgb, var(--conf-low) 14%, transparent); color: var(--conf-low); }
.acct-pill-fail .acct-pill-dot { background: var(--conf-low); }
.acct-pill-idle { background: var(--rule); color: var(--fg-muted); }
.acct-pill-idle .acct-pill-dot { background: var(--fg-muted); }
/* ---- Detail panel -------------------------------------------------- */
.acct-detail {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
padding: 18px 20px;
display: flex; flex-direction: column;
gap: 18px;
position: sticky;
top: 0;
}
.acct-detail-empty {
align-items: center; justify-content: center;
text-align: center;
min-height: 240px;
color: var(--fg-subtle);
}
.acct-detail-empty-lbl {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
color: var(--fg-muted);
margin-bottom: 8px;
}
.acct-detail-empty-sub {
font-size: 12.5px;
max-width: 240px;
line-height: 1.5;
}
.acct-detail-head {
display: flex; align-items: flex-start; justify-content: space-between;
gap: 12px;
}
.acct-detail-eyebrow {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--fg-subtle);
margin-bottom: 4px;
}
.acct-detail-title {
margin: 0;
font-family: var(--font-sans-kr);
font-size: 18px;
font-weight: 500;
color: var(--fg-strong);
}
.acct-detail-sub {
font-size: 11.5px;
color: var(--fg-muted);
letter-spacing: 0.04em;
margin-top: 4px;
}
.acct-detail-stat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0;
border: 1px solid var(--rule);
border-radius: var(--r-sm);
}
.acct-detail-stat {
padding: 10px 12px;
border-right: 1px solid var(--rule);
border-bottom: 1px solid var(--rule);
}
.acct-detail-stat:nth-child(2n) { border-right: none; }
.acct-detail-stat:nth-last-child(-n+2) { border-bottom: none; }
.acct-detail-stat .lbl {
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: var(--fg-subtle);
margin-bottom: 4px;
}
.acct-detail-stat .val {
font-family: var(--font-mono);
font-size: 16px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--fg-strong);
}
.acct-detail-stat .val .ccy { color: var(--fg-subtle); font-weight: 400; font-size: 11px; }
.acct-detail-stat .sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--fg-subtle);
letter-spacing: 0.04em;
margin-top: 2px;
}
.acct-detail-section { display: flex; flex-direction: column; gap: 0; }
.acct-detail-section-head {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.18em;
color: var(--fg-subtle);
margin-bottom: 8px;
}
.acct-kv-row {
display: flex; justify-content: space-between; align-items: center;
padding: 7px 0;
border-bottom: 1px dashed var(--rule);
font-size: 12.5px;
}
.acct-kv-row:last-child { border-bottom: none; }
.acct-kv-row .k {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg-subtle);
}
.acct-kv-row .v { color: var(--fg); display: inline-flex; align-items: center; gap: 4px; }
.acct-mini-log {
background: var(--surface);
border: 1px solid var(--rule-ink);
border-radius: var(--r-sm);
padding: 4px 0;
}
.acct-mini-row {
display: grid;
grid-template-columns: 60px 1fr;
gap: 10px;
padding: 7px 12px;
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--fg-on-ink);
border-bottom: 1px dashed var(--rule-ink);
}
.acct-mini-row:last-child { border-bottom: none; }
.acct-mini-row .t { color: var(--fg-on-ink-muted); }
.acct-mini-row.fail .msg { color: var(--conf-low); }
.acct-mini-row.warn .msg { color: var(--conf-mid); }
.acct-detail-actions {
display: flex; flex-wrap: wrap; gap: 6px;
margin-top: 4px;
}
.acct-detail-actions .btn { flex: 1; min-width: 110px; }
.acct-detail-actions .btn.ghost { color: var(--conf-low); border-color: color-mix(in srgb, var(--conf-low) 30%, var(--rule)); }
.acct-detail-actions .btn.ghost:hover { background: color-mix(in srgb, var(--conf-low) 8%, transparent); }
/* ---- Chart of accounts -------------------------------------------- */
.acct-coa {
display: flex; flex-direction: column;
gap: 20px;
}
.acct-coa-group {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
overflow: hidden;
}
.acct-coa-head {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid var(--rule);
background: color-mix(in srgb, var(--paper) 94%, black 6%);
}
.acct-coa-title {
font-family: var(--font-sans-kr);
font-size: 14px;
font-weight: 500;
color: var(--fg-strong);
}
.acct-coa-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg-subtle);
}
.acct-coa-rows { padding: 4px 0; }
.acct-coa-row {
display: grid;
grid-template-columns: 1fr 2fr auto;
gap: 16px;
align-items: center;
padding: 7px 16px;
border-bottom: 1px solid var(--rule);
font-size: 12.5px;
}
.acct-coa-row:last-child { border-bottom: none; }
.acct-coa-row.branch {
background: color-mix(in srgb, var(--paper) 96%, black 4%);
}
.acct-coa-row.branch .acct-coa-leaf {
font-weight: 500;
color: var(--fg-strong);
}
.acct-coa-tree {
display: inline-flex; align-items: center; gap: 6px;
}
.acct-coa-glyph {
font-family: var(--font-mono);
color: var(--fg-subtle);
width: 12px;
text-align: center;
}
.acct-coa-leaf {
font-family: var(--font-sans-kr);
color: var(--fg);
}
.acct-coa-path {
font-size: 11px;
color: var(--fg-subtle);
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.acct-coa-amt {
font-size: 13px;
font-variant-numeric: tabular-nums;
color: var(--fg-strong);
text-align: right;
}
.acct-coa-amt.pos { color: var(--conf-rules); }
.acct-coa-amt.neg { color: var(--fg-strong); }
.acct-coa-amt .ccy { color: var(--fg-subtle); font-weight: 400; }
/* =========================================================================
HISTORY SCREEN
========================================================================= */
.history-screen {
padding: 24px;
overflow-y: auto;
height: 100%;
}
/* Summary strip */
.hist-summary-strip {
display: grid;
grid-template-columns: repeat(6, 1fr);
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
margin-bottom: 16px;
overflow: hidden;
}
.hist-summary-cell {
padding: 12px 16px;
border-right: 1px solid var(--rule);
display: flex; flex-direction: column;
gap: 4px;
}
.hist-summary-cell:last-child { border-right: none; }
.hist-summary-cell .lbl {
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.16em;
color: var(--fg-subtle);
}
.hist-summary-cell .val {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: var(--fg-strong);
line-height: 1;
}
.hist-summary-cell .val.neg { color: var(--conf-low); }
/* Filters */
.hist-filters {
display: flex; flex-direction: column;
gap: 10px;
padding: 12px 16px;
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
margin-bottom: 16px;
}
.hist-filter-row {
display: flex; align-items: center; gap: 12px;
flex-wrap: wrap;
}
.hist-filter-lbl {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.16em;
color: var(--fg-subtle);
min-width: 48px;
}
.hist-filter-chips { display: flex; flex-wrap: wrap; gap: 4px; }
.hist-chip {
display: inline-flex; align-items: center; gap: 6px;
height: 24px;
padding: 0 10px;
background: transparent;
border: 1px solid var(--rule);
border-radius: 2px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
color: var(--fg-muted);
text-transform: uppercase;
}
.hist-chip:hover { color: var(--fg-strong); border-color: var(--fg-muted); }
.hist-chip.active {
background: var(--fg-strong);
border-color: var(--fg-strong);
color: var(--bg);
}
.hist-chip.active .hist-chip-glyph { color: inherit !important; }
.hist-chip-glyph { font-size: 12px; }
/* Table */
.hist-table {
background: var(--paper);
border: 1px solid var(--rule);
border-radius: var(--r-md);
overflow: hidden;
}
.hist-table-head {
display: grid;
grid-template-columns: 78px 110px 80px 60px 1fr 140px;
gap: 14px;
align-items: center;
padding: 10px 16px;
border-bottom: 1px solid var(--rule);
background: color-mix(in srgb, var(--paper) 94%, black 6%);
font-family: var(--font-mono);
font-size: 9.5px;
letter-spacing: 0.18em;
color: var(--fg-subtle);
}
.hist-day { display: flex; flex-direction: column; }
.hist-day-head {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px 6px;
border-bottom: 1px solid var(--rule);
}
.hist-day-lbl {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.18em;
color: var(--fg-strong);
}
.hist-day-date {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--fg-subtle);
}
.hist-day-count {
margin-left: auto;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.14em;
color: var(--fg-subtle);
}
.hist-day-rows { display: flex; flex-direction: column; }
.hist-row {
display: grid;
grid-template-columns: 78px 110px 80px 60px 1fr 140px;
gap: 14px;
align-items: center;
padding: 11px 16px;
border-bottom: 1px solid var(--rule);
}
.hist-row:last-child { border-bottom: none; }
.hist-row:hover { background: color-mix(in srgb, var(--paper) 96%, var(--fg-strong) 4%); }
.hist-time {
font-size: 11.5px;
color: var(--fg-muted);
font-variant-numeric: tabular-nums;
letter-spacing: 0.02em;
}
.hist-col-kind {
display: inline-flex; align-items: center; gap: 6px;
}
.hist-glyph {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1;
width: 14px;
text-align: center;
}
.hist-kind-lbl {
font-family: var(--font-mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.14em;
color: var(--fg-strong);
}
.hist-actor {
display: inline-block;
padding: 1px 6px;
border-radius: 2px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 500;
}
.hist-actor-you { background: var(--fg-strong); color: var(--bg); }
.hist-actor-system { background: var(--rule); color: var(--fg); }
.hist-actor-agent { background: color-mix(in srgb, var(--conf-high) 18%, transparent); color: var(--conf-high); }
.hist-col-summary { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.hist-summary {
font-size: 13px;
color: var(--fg);
font-family: var(--font-sans-kr);
}
.hist-detail {
font-size: 11px;
color: var(--fg-subtle);
letter-spacing: 0.02em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hist-col-ref {
font-size: 11px;
color: var(--fg-muted);
letter-spacing: 0.02em;
text-align: right;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.hist-empty {
padding: 48px 16px;
text-align: center;
color: var(--fg-subtle);
}
.hist-empty-lbl {
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.18em;
color: var(--fg-muted);
margin-bottom: 6px;
}
.hist-empty-sub { font-size: 12.5px; }
/* "all entity" badge (used in history for system events) */
.entity-badge.all {
background: var(--rule);
color: var(--fg-muted);
}
.entity-badge.all::before { background: var(--fg-muted); }

View file

@ -50,6 +50,37 @@ window.AKEFIN_DATA = (() => {
],
};
// Connected data sources (banks, exchanges, file drops)
const connectedAccounts = [
{ id: "src01", name: "Toss · 신한 입출금", kind: "bank", country: "KR", entity: "personal", maskedNo: "•••• 4823", balance: 14200000, ccy: "KRW", lastSync: "2026-03-08 09:14", method: "csv-poll", status: "ok", nextRun: "in 4h", txCount30d: 247 },
{ id: "src02", name: "Kakao Bank · 9TFox", kind: "bank", country: "KR", entity: "tfox", maskedNo: "•••• 1102", balance: 18400000, ccy: "KRW", lastSync: "2026-03-07 18:02", method: "csv-poll", status: "ok", nextRun: "in 12h", txCount30d: 62 },
{ id: "src03", name: "Wise · multi-ccy", kind: "exchange", country: "BE", entity: "personal", maskedNo: "USR-93281", balance: 2840, ccy: "EUR", lastSync: "2026-03-07 12:48", method: "api", status: "ok", nextRun: "in 1h", txCount30d: 41 },
{ id: "src04", name: "Wise · 9TFox", kind: "exchange", country: "BE", entity: "tfox", maskedNo: "USR-93281-B", balance: 176.40, ccy: "EUR", lastSync: "2026-03-07 12:48", method: "api", status: "ok", nextRun: "in 1h", txCount30d: 14 },
{ id: "src05", name: "Garanti BBVA", kind: "bank", country: "TR", entity: "personal", maskedNo: "•••• 7012", balance: 62400, ccy: "TRY", lastSync: "2026-03-06 22:01", method: "csv-drop", status: "warn", nextRun: "manual", txCount30d: 89 },
{ id: "src06", name: "Finacode · partner", kind: "manual", country: "EU", entity: "finacode", maskedNo: "—", balance: 6320, ccy: "EUR", lastSync: "2026-03-03 11:20", method: "manual", status: "idle", nextRun: "—", txCount30d: 3 },
{ id: "src07", name: "MUFG · JPY savings", kind: "bank", country: "JP", entity: "personal", maskedNo: "•••• 9981", balance: 148000, ccy: "JPY", lastSync: "2026-03-05 03:11", method: "csv-drop", status: "fail", nextRun: "manual", txCount30d: 8 },
];
// Append-only audit history — most recent first
const history = [
{ id: "h001", at: "2026-03-08 11:42:18", actor: "you", kind: "commit", entity: "all", summary: "Committed ledger to git", detail: "e4a82c1 · 23 entries · +1.2k 0.1k", ref: "main" },
{ id: "h002", at: "2026-03-08 11:38:02", actor: "you", kind: "approve", entity: "personal", summary: "Approved 18 transactions ≥ 0.85 confidence", detail: "batch via ⌘⇧A", ref: "t14…t31" },
{ id: "h003", at: "2026-03-08 11:36:40", actor: "you", kind: "override", entity: "personal", summary: "Overrode category on 1 transaction", detail: "GS25 역삼점 → Personal:Expenses:Convenience", ref: "t05" },
{ id: "h004", at: "2026-03-08 11:30:09", actor: "you", kind: "rule", entity: "personal", summary: "Promoted rule · 스타벅스 *", detail: "→ Personal:Expenses:Food:Coffee · 14 matches", ref: "r01" },
{ id: "h005", at: "2026-03-08 09:14:00", actor: "system", kind: "import", entity: "personal", summary: "Imported Toss · 신한 입출금 CSV", detail: "247 rows · 153 auto · 44 high · 35 review · 15 failed", ref: "i01" },
{ id: "h006", at: "2026-03-08 09:11:22", actor: "system", kind: "poll", entity: "personal", summary: "Polled Toss for new transactions", detail: "247 new rows · password decrypt ok", ref: "src01" },
{ id: "h007", at: "2026-03-08 06:00:01", actor: "system", kind: "fx", entity: "all", summary: "FX rates refreshed", detail: "KRW=1 · EUR=1456.20 · TRY=41.32 · JPY=9.21", ref: "fx-snap" },
{ id: "h008", at: "2026-03-07 22:48:50", actor: "agent", kind: "categorize", entity: "tfox", summary: "Categorized 12 transactions", detail: "avg confidence 0.89 · 1 unmatched", ref: "agent-run-128" },
{ id: "h009", at: "2026-03-07 18:02:00", actor: "system", kind: "import", entity: "tfox", summary: "Imported Kakao Bank · 9TFox CSV", detail: "62 rows · 58 auto · 3 high · 1 review", ref: "i02" },
{ id: "h010", at: "2026-03-07 12:48:00", actor: "system", kind: "import", entity: "tfox", summary: "Imported Wise · 9TFox via API", detail: "14 rows · 12 auto · 2 high", ref: "i03" },
{ id: "h011", at: "2026-03-07 09:14:30", actor: "you", kind: "skip", entity: "personal", summary: "Skipped & re-queued 1 transaction", detail: "ATM 출금 강남역 · low confidence (0.42)", ref: "t13" },
{ id: "h012", at: "2026-03-06 22:01:00", actor: "system", kind: "import", entity: "personal", summary: "Imported Garanti BBVA CSV", detail: "89 rows · 61 auto · 14 high · 11 review · 3 failed", ref: "i04" },
{ id: "h013", at: "2026-03-06 20:55:14", actor: "you", kind: "rule", entity: "tfox", summary: "Edited rule · Hetzner *", detail: "target → 9TFox:Expenses:Infrastructure", ref: "r03" },
{ id: "h014", at: "2026-03-06 14:12:08", actor: "you", kind: "commit", entity: "all", summary: "Committed ledger to git", detail: "3a17fb9 · 47 entries · pushed to origin/main", ref: "main" },
{ id: "h015", at: "2026-03-05 03:11:00", actor: "system", kind: "error", entity: "personal", summary: "MUFG · JPY savings — CSV parse failed", detail: "Encoding mismatch · expected Shift-JIS, got UTF-8", ref: "src07" },
{ id: "h016", at: "2026-03-04 18:32:11", actor: "you", kind: "approve", entity: "tfox", summary: "Approved 7 transactions", detail: "manual review queue", ref: "t44…t50" },
];
const importRuns = [
{ id: "i01", at: "2026-03-08 09:14", source: "Toss · 신한 입출금", rows: 247, auto: 153, high: 44, review: 35, failed: 15, entity: "personal" },
{ id: "i02", at: "2026-03-07 18:02", source: "Kakao Bank · 9TFox", rows: 62, auto: 58, high: 3, review: 1, failed: 0, entity: "tfox" },
@ -110,5 +141,5 @@ window.AKEFIN_DATA = (() => {
"Income:Consulting", "Income:PartnerShare", "Cash",
];
return { transactions, ruleSuggestions, accountsByEntity, importRuns, entities, allAccounts, fx, convert, commands, categories };
return { transactions, ruleSuggestions, accountsByEntity, importRuns, entities, allAccounts, fx, convert, commands, categories, connectedAccounts, history };
})();

View file

@ -25,6 +25,8 @@
<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="AccountsScreen.jsx"></script>
<script type="text/babel" src="HistoryScreen.jsx"></script>
<script type="text/babel" src="App.jsx"></script>
</body>
</html>