feat: accounts and history pages are in place
This commit is contained in:
parent
09fc5dc367
commit
8c42d246ea
7 changed files with 1135 additions and 3 deletions
244
ui_kits/web/AccountsScreen.jsx
Normal file
244
ui_kits/web/AccountsScreen.jsx
Normal 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 {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> {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 });
|
||||
})();
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
245
ui_kits/web/HistoryScreen.jsx
Normal file
245
ui_kits/web/HistoryScreen.jsx
Normal 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 });
|
||||
})();
|
||||
|
|
@ -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); }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue