first commit
This commit is contained in:
commit
8b790b7601
86 changed files with 6348 additions and 0 deletions
154
ui_kits/web/App.jsx
Normal file
154
ui_kits/web/App.jsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// App — composition root
|
||||
|
||||
function App() {
|
||||
const [entity, setEntity] = React.useState("all");
|
||||
const [screen, setScreen] = React.useState("review");
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [sort, setSort] = React.useState("date-desc");
|
||||
const [transactions, setTransactions] = React.useState(window.AKEFIN_DATA.transactions);
|
||||
const [selectedId, setSelectedId] = React.useState(window.AKEFIN_DATA.transactions[0].id);
|
||||
const [toast, setToast] = React.useState(null);
|
||||
const [paletteOpen, setPaletteOpen] = React.useState(false);
|
||||
const [csvOpen, setCsvOpen] = React.useState(false);
|
||||
const [theme, setTheme] = React.useState("light");
|
||||
const [filters, setFilters] = React.useState({
|
||||
dateRange: "all",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
categories: new Set(),
|
||||
tiers: new Set(),
|
||||
confidenceMin: 0,
|
||||
ccy: new Set(),
|
||||
});
|
||||
|
||||
// Set theme on root
|
||||
React.useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
const showToast = (msg) => {
|
||||
setToast(msg);
|
||||
setTimeout(() => setToast(null), 2400);
|
||||
};
|
||||
|
||||
// ⌘K to open command palette
|
||||
React.useEffect(() => {
|
||||
const onKey = (e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setPaletteOpen(o => !o);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
|
||||
const approve = (id, account) => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== id));
|
||||
const next = transactions.find(t => t.id !== id);
|
||||
if (next) setSelectedId(next.id);
|
||||
showToast(`Approved · posted to ${account || "—"}`);
|
||||
};
|
||||
|
||||
const skip = (id) => {
|
||||
setTransactions(prev => prev.filter(t => t.id !== id));
|
||||
const next = transactions.find(t => t.id !== id);
|
||||
if (next) setSelectedId(next.id);
|
||||
showToast(`Skipped · t${id.slice(1)} held for re-review`);
|
||||
};
|
||||
|
||||
const runCommand = (cmd) => {
|
||||
setPaletteOpen(false);
|
||||
switch (cmd.id) {
|
||||
case "approve":
|
||||
if (selected) approve(selected.id, selected.suggestedAccount);
|
||||
break;
|
||||
case "skip":
|
||||
if (selected) skip(selected.id);
|
||||
break;
|
||||
case "approve-all-high":
|
||||
const ids = transactions.filter(t => (t.score ?? 0) >= 0.85).map(t => t.id);
|
||||
setTransactions(prev => prev.filter(t => !ids.includes(t.id)));
|
||||
showToast(`Approved ${ids.length} transactions at ≥ 0.85`);
|
||||
break;
|
||||
case "import-csv":
|
||||
setCsvOpen(true);
|
||||
break;
|
||||
case "poll-toss":
|
||||
showToast("Polling Toss · ETA 4 seconds");
|
||||
break;
|
||||
case "promote-rules":
|
||||
setScreen("rules");
|
||||
break;
|
||||
case "commit-ledger":
|
||||
showToast("Committed · e4a82c1 on main · pushed");
|
||||
break;
|
||||
case "switch-personal": setEntity("personal"); break;
|
||||
case "switch-9tfox": setEntity("tfox"); break;
|
||||
case "switch-finacode": setEntity("finacode"); break;
|
||||
case "go-review": setScreen("review"); break;
|
||||
case "go-rules": setScreen("rules"); break;
|
||||
case "go-ledger": setScreen("ledger"); break;
|
||||
case "go-import": setScreen("import"); break;
|
||||
case "toggle-theme": setTheme(t => t === "light" ? "dark" : "light"); break;
|
||||
default: showToast(`${cmd.label}`);
|
||||
}
|
||||
};
|
||||
|
||||
const visibleTx = transactions.filter(t => entity === "all" || t.entity === entity);
|
||||
const selected = visibleTx.find(t => t.id === selectedId) || visibleTx[0] || null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (visibleTx.length && !visibleTx.find(t => t.id === selectedId)) {
|
||||
setSelectedId(visibleTx[0].id);
|
||||
}
|
||||
}, [entity, transactions]);
|
||||
|
||||
const counts = {
|
||||
review: transactions.filter(t => entity === "all" || t.entity === entity).length,
|
||||
rules: window.AKEFIN_DATA.ruleSuggestions.length,
|
||||
import: window.AKEFIN_DATA.importRuns.length,
|
||||
};
|
||||
|
||||
const statusLine = `${counts.review} staged · ledger clean · last import 4m ago`;
|
||||
|
||||
const handleImport = ({ entity: e, file, password }) => {
|
||||
setCsvOpen(false);
|
||||
showToast(`Importing · decrypting with password · ${e} entity`);
|
||||
setTimeout(() => showToast(`Import complete · 247 rows · 35 staged for review`), 1800);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ak-app">
|
||||
<Header entity={entity} onEntityChange={setEntity}
|
||||
query={query} setQuery={setQuery}
|
||||
statusLine={statusLine}
|
||||
onOpenPalette={() => setPaletteOpen(true)}
|
||||
onImport={() => setCsvOpen(true)} />
|
||||
<div className="ak-body">
|
||||
<Sidebar screen={screen} setScreen={setScreen} counts={counts} />
|
||||
<main className="ak-main">
|
||||
{screen === "review" && (
|
||||
<div className="review-screen">
|
||||
<ReviewQueue entity={entity} transactions={transactions}
|
||||
selectedId={selected?.id} onSelect={setSelectedId}
|
||||
sort={sort} setSort={setSort}
|
||||
filters={filters} setFilters={setFilters}
|
||||
onOpenPalette={() => setPaletteOpen(true)} />
|
||||
<Inspector tx={selected} onApprove={approve} onSkip={skip} />
|
||||
</div>
|
||||
)}
|
||||
{screen === "rules" && <RulesScreen entity={entity} />}
|
||||
{screen === "ledger" && <LedgerScreen entity={entity} />}
|
||||
{screen === "import" && <ImportScreen entity={entity} onOpenImport={() => setCsvOpen(true)} />}
|
||||
</main>
|
||||
</div>
|
||||
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} onRun={runCommand} />
|
||||
<CsvImportModal open={csvOpen} onClose={() => setCsvOpen(false)} onSubmit={handleImport} />
|
||||
{toast && <div className="ak-toast">{toast}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(<App />);
|
||||
128
ui_kits/web/Atoms.jsx
Normal file
128
ui_kits/web/Atoms.jsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// Atoms — buttons, chips, badges, inputs, terminal frame
|
||||
// Exported to window for cross-script use.
|
||||
|
||||
const fmt = (n, ccy) => {
|
||||
if (n === null || n === undefined) return "—";
|
||||
const abs = Math.abs(n);
|
||||
const formatted = abs.toLocaleString("en-US", { maximumFractionDigits: ccy === "EUR" ? 2 : 0 });
|
||||
return formatted;
|
||||
};
|
||||
const sign = (n) => (n > 0 ? "+ " : n < 0 ? "− " : "");
|
||||
|
||||
// --- ENTITY BADGE ---
|
||||
function EntityBadge({ entity, size = "md" }) {
|
||||
const map = {
|
||||
personal: { label: "Personal", cls: "personal" },
|
||||
tfox: { label: "9TFox", cls: "tfox" },
|
||||
finacode: { label: "Finacode", cls: "finacode" },
|
||||
};
|
||||
const e = map[entity];
|
||||
if (!e) return null;
|
||||
return <span className={`entity-badge ${e.cls} sz-${size}`}>{e.label}</span>;
|
||||
}
|
||||
|
||||
// --- CONFIDENCE CHIP ---
|
||||
function ConfidenceChip({ score, tier }) {
|
||||
let cls = "low";
|
||||
if (tier === "rules" || (score !== null && score >= 1)) cls = "rules";
|
||||
else if (score !== null && score >= 0.85) cls = "high";
|
||||
else if (score !== null && score >= 0.70) cls = "mid";
|
||||
const display = score === null ? "—" : score.toFixed(2);
|
||||
return (
|
||||
<span className={`chip ${cls}`}>
|
||||
<span className="star">★</span>
|
||||
<span className="val">{display}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- TIER BADGE ---
|
||||
function TierBadge({ tier }) {
|
||||
const map = { rules: "Rules", llm: "LLM", agent: "Agent", unmatched: "Unmatched" };
|
||||
return <span className={`tier ${tier}`}>{map[tier]}</span>;
|
||||
}
|
||||
|
||||
// --- AMOUNT ---
|
||||
function Amount({ value, ccy, alignRight = true }) {
|
||||
if (value === null || value === undefined) return <span className="amount muted">—</span>;
|
||||
const cls = value > 0 ? "pos" : value < 0 ? "neg" : "zero";
|
||||
return (
|
||||
<span className={`amount ${cls}`} style={{ textAlign: alignRight ? "right" : "left" }}>
|
||||
{sign(value)}{fmt(value, ccy)}<span className="ccy"> {ccy}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// --- BUTTONS ---
|
||||
function Btn({ children, variant = "ghost", onClick, type = "button", bracket = false, disabled = false }) {
|
||||
const cls = bracket ? "btn bracket" : `btn ${variant}`;
|
||||
return (
|
||||
<button className={cls} onClick={onClick} type={type} disabled={disabled}>
|
||||
{bracket ? `[ ${children} ]` : children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// --- INPUT ---
|
||||
function TextInput({ value, onChange, placeholder, mono = false, align = "left", icon = null }) {
|
||||
return (
|
||||
<div className={`text-input ${mono ? "mono" : ""}`}>
|
||||
{icon && <span className="ti-icon">{icon}</span>}
|
||||
<input
|
||||
type="text"
|
||||
value={value || ""}
|
||||
onChange={(e) => onChange && onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
style={{ textAlign: align }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- TERMINAL FRAME ---
|
||||
function TermFrame({ title, status = "ok", children, scrollable = false }) {
|
||||
const dot = status === "ok" ? "var(--conf-rules)" : status === "warn" ? "var(--conf-mid)" : "var(--conf-low)";
|
||||
return (
|
||||
<div className="term-frame">
|
||||
<div className="term-frame-title">
|
||||
<span>{title}</span>
|
||||
<span className="dot" style={{ background: dot }}></span>
|
||||
</div>
|
||||
<div className={`term-frame-body ${scrollable ? "scrollable" : ""}`}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- ICON (lucide subset, inlined) ---
|
||||
const ICONS = {
|
||||
check: "M20 6 L9 17 L4 12",
|
||||
x: "M18 6 L6 18 M6 6 L18 18",
|
||||
search: "M11 4 a7 7 0 1 0 0 14 a7 7 0 0 0 0 -14 M21 21 L16.65 16.65",
|
||||
chevDown: "M6 9 L12 15 L18 9",
|
||||
chevRight: "M9 6 L15 12 L9 18",
|
||||
plus: "M12 5 L12 19 M5 12 L19 12",
|
||||
trash: "M3 6 L21 6 M19 6 L17 20 a2 2 0 0 1 -2 2 H9 a2 2 0 0 1 -2 -2 L5 6 M10 11 L10 17 M14 11 L14 17",
|
||||
clock: "M21 12 a9 9 0 1 1 -18 0 a9 9 0 0 1 18 0 M12 7 L12 12 L15 14",
|
||||
card: "M3 4 H21 V20 H3 Z M3 10 L21 10",
|
||||
import: "M12 2 L12 22 M19 15 L12 22 L5 15",
|
||||
activity: "M22 12 H18 L15 21 L9 3 L6 12 H2",
|
||||
filter: "M3 5 H21 L14 13 V20 L10 22 V13 L3 5",
|
||||
sort: "M3 6 H21 M6 12 H18 M9 18 H15",
|
||||
ellipsis: "M5 12 h.01 M12 12 h.01 M19 12 h.01",
|
||||
arrowRight: "M5 12 H19 M13 6 L19 12 L13 18",
|
||||
rule: "M4 4 H20 V20 H4 Z M4 9 H20 M4 14 H20 M9 9 V20",
|
||||
ledger: "M5 3 H17 a2 2 0 0 1 2 2 V21 L12 17 L5 21 Z",
|
||||
refresh: "M21 12 a9 9 0 1 1 -3 -6.7 M21 4 V10 H15",
|
||||
spark: "M5 12 L9 8 L13 14 L15 11 L19 12",
|
||||
};
|
||||
function Icon({ name, size = 16, stroke = 1.5, color = "currentColor" }) {
|
||||
const d = ICONS[name];
|
||||
if (!d) return null;
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<path d={d}></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Btn, TextInput, EntityBadge, ConfidenceChip, TierBadge, Amount, TermFrame, Icon, fmt, sign });
|
||||
108
ui_kits/web/Chrome.jsx
Normal file
108
ui_kits/web/Chrome.jsx
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
// Chrome — top header strip + left navigation rail
|
||||
|
||||
function EntityScopeSwitcher({ value, onChange }) {
|
||||
const entities = window.AKEFIN_DATA.entities;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const current = entities.find(e => e.id === value);
|
||||
return (
|
||||
<div className={`scope-switcher ${open ? "open" : ""}`}>
|
||||
<button className="scope-trigger" onClick={() => setOpen(!open)}>
|
||||
<span className="scope-stripe" style={{ background: current.color || "#1A1814" }}></span>
|
||||
<span className="scope-label">SCOPE</span>
|
||||
<span className="scope-name">{current.label}</span>
|
||||
<Icon name="chevDown" size={14} />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="scope-menu">
|
||||
{entities.map(e => (
|
||||
<button key={e.id} className={`scope-item ${e.id === value ? "active" : ""}`}
|
||||
onClick={() => { onChange(e.id); setOpen(false); }}>
|
||||
<span className="scope-stripe" style={{ background: e.color || "#B5AE9F" }}></span>
|
||||
<span>{e.label}</span>
|
||||
{e.id === value && <Icon name="check" size={12} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({ entity, onEntityChange, query, setQuery, statusLine, onOpenPalette, onImport }) {
|
||||
return (
|
||||
<header className="ak-header">
|
||||
<div className="ak-logo">
|
||||
<svg viewBox="0 0 48 48" width="22" height="22" aria-hidden="true">
|
||||
<g fill="currentColor">
|
||||
<rect x="6" y="14" width="6" height="28"/>
|
||||
<rect x="12" y="8" width="6" height="6"/>
|
||||
<rect x="18" y="8" width="6" height="6"/>
|
||||
<rect x="24" y="14" width="6" height="28"/>
|
||||
<rect x="12" y="26" width="12" height="6"/>
|
||||
</g>
|
||||
<rect x="36" y="36" width="6" height="6" fill="#2F7D55"/>
|
||||
</svg>
|
||||
<span className="ak-wordmark">akefin</span>
|
||||
</div>
|
||||
<EntityScopeSwitcher value={entity} onChange={onEntityChange} />
|
||||
<button className="ak-search ak-search-btn" onClick={onOpenPalette}>
|
||||
<Icon name="search" size={14} color="var(--fg-muted)" />
|
||||
<span className="ak-search-placeholder">Search · run command · jump to anything…</span>
|
||||
<span className="ak-search-kbd">⌘K</span>
|
||||
</button>
|
||||
<div className="ak-header-actions">
|
||||
<button className="ak-header-btn" onClick={onImport}>
|
||||
<Icon name="import" size={13} />
|
||||
<span>IMPORT CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="ak-status">
|
||||
<span className="dot" style={{ background: "var(--conf-rules)" }}></span>
|
||||
<span className="ak-status-text">{statusLine}</span>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar({ screen, setScreen, counts }) {
|
||||
const items = [
|
||||
{ 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 },
|
||||
{ id: "import", label: "Import", icon: "import", count: counts.import },
|
||||
];
|
||||
return (
|
||||
<nav className="ak-sidebar">
|
||||
<div className="ak-sb-section">PIPELINE</div>
|
||||
{items.map(it => (
|
||||
<button key={it.id}
|
||||
className={`ak-sb-item ${screen === it.id ? "active" : ""}`}
|
||||
onClick={() => setScreen(it.id)}>
|
||||
<Icon name={it.icon} size={16} />
|
||||
<span className="ak-sb-label">{it.label}</span>
|
||||
{it.count !== null && <span className="ak-sb-count">{it.count}</span>}
|
||||
</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>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<div className="ak-sb-foot">
|
||||
<div className="ak-sb-foot-row">
|
||||
<span className="lbl">Ledger</span>
|
||||
<span className="val mono">9tfox-2026-03.ldgr</span>
|
||||
</div>
|
||||
<div className="ak-sb-foot-row">
|
||||
<span className="lbl">Branch</span>
|
||||
<span className="val mono">main · clean</span>
|
||||
</div>
|
||||
<div className="ak-sb-foot-row">
|
||||
<span className="lbl">Last push</span>
|
||||
<span className="val mono">4m ago</span>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Header, Sidebar });
|
||||
295
ui_kits/web/CommandFilter.jsx
Normal file
295
ui_kits/web/CommandFilter.jsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
// CommandPalette + FilterPanel + CSV password modal
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// COMMAND PALETTE
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function CommandPalette({ open, onClose, onRun }) {
|
||||
const [q, setQ] = React.useState("");
|
||||
const [active, setActive] = React.useState(0);
|
||||
const inputRef = React.useRef(null);
|
||||
const commands = window.AKEFIN_DATA.commands;
|
||||
|
||||
// Filter
|
||||
const filtered = q
|
||||
? commands.filter(c => c.label.toLowerCase().includes(q.toLowerCase()) || c.id.includes(q.toLowerCase()))
|
||||
: commands;
|
||||
|
||||
// Group by kind
|
||||
const groups = {};
|
||||
filtered.forEach(c => {
|
||||
if (!groups[c.kind]) groups[c.kind] = [];
|
||||
groups[c.kind].push(c);
|
||||
});
|
||||
const groupOrder = ["action", "batch", "nav", "search", "setting"];
|
||||
const groupLabels = { action: "ACTIONS", batch: "BATCH", nav: "NAVIGATE", search: "SEARCH", setting: "SETTINGS" };
|
||||
|
||||
// Flat ordered list for keyboard nav
|
||||
const flat = [];
|
||||
groupOrder.forEach(g => { if (groups[g]) flat.push(...groups[g]); });
|
||||
|
||||
React.useEffect(() => { setActive(0); }, [q]);
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setQ("");
|
||||
setTimeout(() => inputRef.current?.focus(), 10);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e) => {
|
||||
if (e.key === "Escape") { onClose(); }
|
||||
else if (e.key === "ArrowDown") { e.preventDefault(); setActive(a => Math.min(flat.length - 1, a + 1)); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setActive(a => Math.max(0, a - 1)); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); if (flat[active]) onRun(flat[active]); }
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, flat, active, onClose, onRun]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
let idx = -1;
|
||||
return (
|
||||
<div className="cp-overlay" onClick={onClose}>
|
||||
<div className="cp-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="cp-head">
|
||||
<Icon name="search" size={14} color="var(--fg-muted)" />
|
||||
<input ref={inputRef} type="text" value={q} onChange={e => setQ(e.target.value)}
|
||||
placeholder="Search commands · accounts · payees…" />
|
||||
<span className="cp-kbd">ESC</span>
|
||||
</div>
|
||||
<div className="cp-body">
|
||||
{groupOrder.filter(g => groups[g]?.length).map(g => (
|
||||
<div key={g} className="cp-group">
|
||||
<div className="cp-group-label">{groupLabels[g]}</div>
|
||||
{groups[g].map(c => {
|
||||
idx++;
|
||||
const isActive = idx === active;
|
||||
return (
|
||||
<button key={c.id}
|
||||
className={`cp-item ${isActive ? "active" : ""}`}
|
||||
onMouseEnter={() => setActive(idx)}
|
||||
onClick={() => onRun(c)}>
|
||||
<span className="cp-icon"><Icon name={c.icon} size={14} color="currentColor" /></span>
|
||||
<span className="cp-label">{c.label}</span>
|
||||
<span className="cp-hint">{c.hint}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{flat.length === 0 && (
|
||||
<div className="cp-empty">No commands match · try "approve" or "import"</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="cp-foot">
|
||||
<span><span className="cp-key">↑↓</span> navigate</span>
|
||||
<span><span className="cp-key">⏎</span> run</span>
|
||||
<span><span className="cp-key">esc</span> close</span>
|
||||
<span style={{ marginLeft: "auto" }}>{flat.length} commands</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// FILTER PANEL — for review queue
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
const DEFAULT_FILTERS = {
|
||||
dateRange: "all",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
categories: new Set(),
|
||||
tiers: new Set(),
|
||||
confidenceMin: 0,
|
||||
ccy: new Set(),
|
||||
};
|
||||
|
||||
function FilterPanel({ open, filters, setFilters, onClose, onApply, activeCount }) {
|
||||
const cats = window.AKEFIN_DATA.categories;
|
||||
if (!open) return null;
|
||||
|
||||
const toggleSet = (key, val) => {
|
||||
setFilters(prev => {
|
||||
const s = new Set(prev[key]);
|
||||
s.has(val) ? s.delete(val) : s.add(val);
|
||||
return { ...prev, [key]: s };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="filter-panel">
|
||||
<div className="filter-row">
|
||||
<div className="filter-eyebrow">DATE</div>
|
||||
<div className="filter-options">
|
||||
{["today", "7d", "30d", "this-month", "all", "custom"].map(d => (
|
||||
<button key={d}
|
||||
className={`filter-pill ${filters.dateRange === d ? "active" : ""}`}
|
||||
onClick={() => setFilters(f => ({ ...f, dateRange: d }))}>
|
||||
{d === "7d" ? "LAST 7d" : d === "30d" ? "LAST 30d" : d.replace("-", " ").toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
{filters.dateRange === "custom" && (
|
||||
<span className="filter-custom-dates">
|
||||
<input className="filter-date" type="date" value={filters.dateFrom} onChange={e => setFilters(f => ({...f, dateFrom: e.target.value}))} />
|
||||
<span className="muted">→</span>
|
||||
<input className="filter-date" type="date" value={filters.dateTo} onChange={e => setFilters(f => ({...f, dateTo: e.target.value}))} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-eyebrow">CATEGORY</div>
|
||||
<div className="filter-options">
|
||||
{cats.map(c => (
|
||||
<button key={c}
|
||||
className={`filter-pill ${filters.categories.has(c) ? "active" : ""}`}
|
||||
onClick={() => toggleSet("categories", c)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-eyebrow">TIER</div>
|
||||
<div className="filter-options">
|
||||
{["rules", "llm", "agent", "unmatched"].map(t => (
|
||||
<button key={t}
|
||||
className={`filter-pill tier-${t} ${filters.tiers.has(t) ? "active" : ""}`}
|
||||
onClick={() => toggleSet("tiers", t)}>
|
||||
{t.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-eyebrow">CCY</div>
|
||||
<div className="filter-options">
|
||||
{["KRW", "TRY", "JPY", "EUR", "USD"].map(c => (
|
||||
<button key={c}
|
||||
className={`filter-pill ${filters.ccy.has(c) ? "active" : ""}`}
|
||||
onClick={() => toggleSet("ccy", c)}>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-row">
|
||||
<div className="filter-eyebrow">MIN CONFIDENCE</div>
|
||||
<div className="filter-slider-wrap">
|
||||
<input className="filter-slider" type="range" min="0" max="1" step="0.05"
|
||||
value={filters.confidenceMin}
|
||||
onChange={e => setFilters(f => ({...f, confidenceMin: parseFloat(e.target.value)}))} />
|
||||
<span className="filter-conf-val mono">{filters.confidenceMin.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="filter-foot">
|
||||
<span className="filter-active-count">
|
||||
{activeCount > 0 ? `${activeCount} filter${activeCount > 1 ? "s" : ""} active` : "No active filters"}
|
||||
</span>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<button className="btn" onClick={() => setFilters({ ...DEFAULT_FILTERS, categories: new Set(), tiers: new Set(), ccy: new Set() })}>CLEAR</button>
|
||||
<button className="btn primary" onClick={onClose}>DONE</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// CSV IMPORT MODAL — with password
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
function CsvImportModal({ open, onClose, onSubmit }) {
|
||||
const [file, setFile] = React.useState(null);
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [entity, setEntity] = React.useState("personal");
|
||||
const [showPw, setShowPw] = React.useState(false);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal" onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-head">
|
||||
<div className="modal-eyebrow">IMPORT · TIER 0</div>
|
||||
<h2 className="modal-title">Import Toss CSV</h2>
|
||||
<button className="modal-close" onClick={onClose}><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="form-field">
|
||||
<label className="form-lbl">CSV FILE</label>
|
||||
<div className="file-drop">
|
||||
<Icon name="import" size={16} color="var(--fg-muted)" />
|
||||
<span className="file-drop-text">
|
||||
{file
|
||||
? <span className="mono">{file}</span>
|
||||
: <>Drop CSV here, or <button className="file-pick" onClick={() => setFile("toss-export-2026-03.csv")}>choose file</button></>}
|
||||
</span>
|
||||
<span className="file-meta">.csv · UTF-8</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-lbl">PASSWORD <span className="form-lbl-note">— Toss exports are usually encrypted with your account password</span></label>
|
||||
<div className="pw-input">
|
||||
<input
|
||||
type={showPw ? "text" : "password"}
|
||||
placeholder="Required for encrypted exports"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
<button className="pw-toggle" onClick={() => setShowPw(s => !s)}>{showPw ? "HIDE" : "SHOW"}</button>
|
||||
</div>
|
||||
<div className="form-help">Password is held in memory only · never written to disk or git · cleared on import complete</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-lbl">ENTITY</label>
|
||||
<div className="entity-radio">
|
||||
{[
|
||||
{ id: "personal", label: "Personal", color: "var(--entity-personal)" },
|
||||
{ id: "tfox", label: "9TFox", color: "var(--entity-9tfox)" },
|
||||
{ id: "finacode", label: "Finacode", color: "var(--entity-finacode)" },
|
||||
].map(e => (
|
||||
<label key={e.id} className={`entity-radio-item ${entity === e.id ? "active" : ""}`}>
|
||||
<input type="radio" name="entity" checked={entity === e.id} onChange={() => setEntity(e.id)} />
|
||||
<span className="stripe" style={{ background: e.color }}></span>
|
||||
<span>{e.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label className="form-lbl">PIPELINE</label>
|
||||
<div className="pipeline-stack term-frame" style={{ marginBottom: 0 }}>
|
||||
<div className="term-frame-body" style={{ paddingTop: 8, paddingBottom: 8 }}>
|
||||
<span className="m">→</span> decrypt (password)
|
||||
{"\n"}<span className="m">→</span> parse 247 rows estimated
|
||||
{"\n"}<span className="m">→</span> tier 1: 47 active rules
|
||||
{"\n"}<span className="m">→</span> tier 2: LLM (gpt-4o-mini)
|
||||
{"\n"}<span className="m">→</span> tier 3: agent (claude-haiku)
|
||||
{"\n"}<span className="m">→</span> stage unmatched for review
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot">
|
||||
<Btn onClick={onClose}>CANCEL</Btn>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<Btn variant="primary" onClick={() => { onSubmit({ file, password, entity }); }}>
|
||||
<Icon name="import" size={13} /> RUN IMPORT
|
||||
</Btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { CommandPalette, FilterPanel, CsvImportModal, DEFAULT_FILTERS });
|
||||
89
ui_kits/web/ImportScreen.jsx
Normal file
89
ui_kits/web/ImportScreen.jsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// ImportScreen — recent import runs and status
|
||||
|
||||
function ImportBar({ run }) {
|
||||
const total = run.rows;
|
||||
const pct = (n) => (n / total) * 100;
|
||||
return (
|
||||
<div className="import-bar">
|
||||
<div style={{ width: `${pct(run.auto)}%`, background: "var(--conf-rules)" }} title={`auto ${run.auto}`}></div>
|
||||
<div style={{ width: `${pct(run.high)}%`, background: "var(--conf-high)" }} title={`high ${run.high}`}></div>
|
||||
<div style={{ width: `${pct(run.review)}%`, background: "var(--conf-mid)" }} title={`review ${run.review}`}></div>
|
||||
<div style={{ width: `${pct(run.failed)}%`, background: "var(--conf-low)" }} title={`failed ${run.failed}`}></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportScreen({ entity, onOpenImport }) {
|
||||
const runs = window.AKEFIN_DATA.importRuns.filter(r => entity === "all" || r.entity === entity);
|
||||
|
||||
const total = runs.reduce((acc, r) => ({
|
||||
rows: acc.rows + r.rows,
|
||||
auto: acc.auto + r.auto,
|
||||
high: acc.high + r.high,
|
||||
review: acc.review + r.review,
|
||||
failed: acc.failed + r.failed,
|
||||
}), { rows: 0, auto: 0, high: 0, review: 0, failed: 0 });
|
||||
|
||||
return (
|
||||
<div className="import-screen">
|
||||
<div className="screen-head">
|
||||
<div>
|
||||
<h1 className="screen-title">Import status</h1>
|
||||
<div className="screen-sub">{runs.length} recent runs · {total.rows} rows · {total.failed} failed</div>
|
||||
</div>
|
||||
<div className="screen-actions">
|
||||
<Btn><Icon name="refresh" size={13} /> POLL TOSS</Btn>
|
||||
<Btn variant="primary" onClick={onOpenImport}><Icon name="import" size={13} /> IMPORT FILE…</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="import-stats">
|
||||
<div className="stat"><span className="stat-num">{total.rows}</span><span className="stat-lbl">TOTAL ROWS</span></div>
|
||||
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-rules)" }}>{total.auto}</span><span className="stat-lbl">AUTO · TIER 1</span></div>
|
||||
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-high)" }}>{total.high}</span><span className="stat-lbl">HIGH · TIER 2</span></div>
|
||||
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-mid)" }}>{total.review}</span><span className="stat-lbl">REVIEW · TIER 3</span></div>
|
||||
<div className="stat"><span className="stat-num" style={{ color: "var(--conf-low)" }}>{total.failed}</span><span className="stat-lbl">FAILED</span></div>
|
||||
</div>
|
||||
|
||||
<TermFrame title="AKEFIN — IMPORT PIPELINE" status="ok">
|
||||
<span className="m">$</span> akefin import --watch{"\n"}
|
||||
<span className="m">polling 4 sources · last poll 4 min ago · next in 11 min</span>{"\n"}
|
||||
<span className="m">tier 1 rules:</span> 47 active <span className="m">·</span> tier 2 llm: gpt-4o-mini <span className="m">·</span> tier 3 agent: claude-haiku
|
||||
</TermFrame>
|
||||
|
||||
<div className="import-list">
|
||||
<div className="import-list-head">
|
||||
<span>RUN AT</span>
|
||||
<span>SOURCE</span>
|
||||
<span>ENTITY</span>
|
||||
<span style={{textAlign:"right"}}>ROWS</span>
|
||||
<span>BREAKDOWN</span>
|
||||
<span></span>
|
||||
</div>
|
||||
{runs.map(r => (
|
||||
<div key={r.id} className="import-row">
|
||||
<span className="mono">{r.at}</span>
|
||||
<span>{r.source}</span>
|
||||
<span><EntityBadge entity={r.entity} /></span>
|
||||
<span className="mono right">{r.rows}</span>
|
||||
<span className="import-bar-cell">
|
||||
<ImportBar run={r} />
|
||||
<span className="import-counts">
|
||||
<span style={{color:"var(--conf-rules)"}}>{r.auto}</span>
|
||||
<span> · </span>
|
||||
<span style={{color:"var(--conf-high)"}}>{r.high}</span>
|
||||
<span> · </span>
|
||||
<span style={{color:"var(--conf-mid)"}}>{r.review}</span>
|
||||
<span> · </span>
|
||||
<span style={{color:"var(--conf-low)"}}>{r.failed}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span><Btn bracket>OPEN</Btn></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ImportScreen });
|
||||
190
ui_kits/web/Inspector.jsx
Normal file
190
ui_kits/web/Inspector.jsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// Inspector — right pane for selected transaction
|
||||
// Shows: source detail, account picker, ledger preview, approve/override/skip
|
||||
|
||||
function AccountPicker({ value, onChange, mru = [] }) {
|
||||
const accounts = window.AKEFIN_DATA.allAccounts;
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [q, setQ] = React.useState("");
|
||||
|
||||
const matches = q
|
||||
? accounts.filter(a => a.toLowerCase().includes(q.toLowerCase())).slice(0, 8)
|
||||
: accounts.slice(0, 8);
|
||||
|
||||
return (
|
||||
<div className={`acct-picker ${open ? "open" : ""}`}>
|
||||
<button className="acct-trigger" onClick={() => setOpen(!open)}>
|
||||
<span className="acct-path">
|
||||
{value ? value.split(":").map((seg, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
<span className={i === arr.length - 1 ? "leaf" : "branch"}>{seg}</span>
|
||||
{i < arr.length - 1 && <span className="sep">:</span>}
|
||||
</React.Fragment>
|
||||
)) : <span className="muted">Choose account…</span>}
|
||||
</span>
|
||||
<Icon name="chevDown" size={14} color="var(--fg-muted)"/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="acct-menu">
|
||||
<div className="acct-search">
|
||||
<Icon name="search" size={13} color="var(--fg-muted)" />
|
||||
<input autoFocus type="text" value={q} onChange={(e)=>setQ(e.target.value)} placeholder="Search accounts…" />
|
||||
</div>
|
||||
{mru.length > 0 && !q && (
|
||||
<div className="acct-section">
|
||||
<div className="acct-label">RECENT</div>
|
||||
{mru.map(a => (
|
||||
<button key={a} className="acct-item" onClick={()=>{onChange(a); setOpen(false); setQ("");}}>
|
||||
<span>{a}</span>
|
||||
<span className="mru-mark">MRU</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="acct-section">
|
||||
<div className="acct-label">{q ? "MATCHES" : "ALL ACCOUNTS"}</div>
|
||||
{matches.map(a => (
|
||||
<button key={a} className="acct-item" onClick={()=>{onChange(a); setOpen(false); setQ("");}}>
|
||||
<span>{a}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="acct-section">
|
||||
<button className="acct-item new">
|
||||
<Icon name="plus" size={12} />
|
||||
<span>Create new account…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerPreview({ tx, account }) {
|
||||
if (!tx || !account) return (
|
||||
<div className="ledger-empty">No posting to preview · choose an account</div>
|
||||
);
|
||||
const entityHomeCcy = { personal: "KRW", tfox: "KRW", finacode: "EUR" };
|
||||
const sourceAccount = tx.entity === "personal" ? "Personal:Assets:Bank:Toss"
|
||||
: tx.entity === "tfox" ? "9TFox:Assets:Bank:Kakao"
|
||||
: "Finacode:Assets:Bank:Wise";
|
||||
const isExpense = tx.amount < 0;
|
||||
const debit = isExpense ? account : sourceAccount;
|
||||
const credit = isExpense ? sourceAccount : account;
|
||||
const abs = Math.abs(tx.amount);
|
||||
|
||||
const homeCcy = entityHomeCcy[tx.entity];
|
||||
const needsFx = tx.ccy !== homeCcy;
|
||||
const fxRate = needsFx ? window.AKEFIN_DATA.fx[tx.ccy] / window.AKEFIN_DATA.fx[homeCcy] : null;
|
||||
const converted = needsFx ? Math.round(window.AKEFIN_DATA.convert(abs, tx.ccy, homeCcy)) : null;
|
||||
|
||||
return (
|
||||
<div className="ledger-preview">
|
||||
<div className="lp-head">
|
||||
<span>{tx.date}</span>
|
||||
<span className="lp-payee ko">"{tx.payee}"</span>
|
||||
<span className="lp-conf"><ConfidenceChip score={tx.score} tier={tx.tier} /></span>
|
||||
</div>
|
||||
<div className="lp-body">
|
||||
<div className="lp-leg">
|
||||
<span className="lp-acct">{debit}</span>
|
||||
<span className="lp-amt"> {abs.toLocaleString()}</span>
|
||||
<span className="lp-ccy">{tx.ccy}</span>
|
||||
</div>
|
||||
{needsFx && (
|
||||
<div className="lp-fx-row">
|
||||
<span className="lp-fx-arrow">↳</span>
|
||||
<span className="lp-fx-text">@ <span className="lp-fx-rate">{fxRate.toFixed(2)}</span> {homeCcy} · FX from <span className="lp-fx-meta">ECB · 2026-03-08</span></span>
|
||||
</div>
|
||||
)}
|
||||
<div className="lp-leg">
|
||||
<span className="lp-acct">{credit}</span>
|
||||
<span className="lp-amt">-{(needsFx ? converted : abs).toLocaleString()}</span>
|
||||
<span className="lp-ccy">{needsFx ? homeCcy : tx.ccy}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lp-foot">
|
||||
{needsFx ? `balanced · 2 legs · FX-converted to ${homeCcy}` : "balanced · 2 legs"} · will commit to {tx.entity === "personal" ? "personal" : tx.entity}-2026-03.ldgr
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Inspector({ tx, onApprove, onSkip }) {
|
||||
const [account, setAccount] = React.useState(tx?.suggestedAccount || null);
|
||||
React.useEffect(() => { setAccount(tx?.suggestedAccount || null); }, [tx?.id]);
|
||||
|
||||
const mru = ["Personal:Expenses:Food:Coffee", "Personal:Expenses:Groceries", "9TFox:Expenses:Software"];
|
||||
|
||||
if (!tx) {
|
||||
return (
|
||||
<aside className="inspector empty">
|
||||
<div className="ins-empty">
|
||||
<Icon name="activity" size={28} color="var(--fg-faint)" />
|
||||
<div>Select a transaction to review</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="inspector">
|
||||
<div className="ins-section">
|
||||
<div className="ins-eyebrow">
|
||||
<span>TRANSACTION · {tx.id.toUpperCase()}</span>
|
||||
<button className="ins-more"><Icon name="ellipsis" size={14}/></button>
|
||||
</div>
|
||||
<div className="ins-payee ko">{tx.payee}</div>
|
||||
{tx.payeeNote && <div className="ins-payee-note">{tx.payeeNote}</div>}
|
||||
<div className="ins-meta-grid">
|
||||
<div className="ins-meta">
|
||||
<span className="lbl">DATE</span>
|
||||
<span className="val mono">{tx.date}</span>
|
||||
</div>
|
||||
<div className="ins-meta">
|
||||
<span className="lbl">AMOUNT</span>
|
||||
<span className="val"><Amount value={tx.amount} ccy={tx.ccy} alignRight={false} /></span>
|
||||
</div>
|
||||
<div className="ins-meta">
|
||||
<span className="lbl">SOURCE</span>
|
||||
<span className="val mono">{tx.sourceAccount}</span>
|
||||
</div>
|
||||
<div className="ins-meta">
|
||||
<span className="lbl">ENTITY</span>
|
||||
<span className="val"><EntityBadge entity={tx.entity} /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ins-section">
|
||||
<div className="ins-eyebrow"><span>AI SUGGESTION</span><ConfidenceChip score={tx.score} tier={tx.tier} /></div>
|
||||
<div className="ins-suggestion">
|
||||
<span className="ins-arrow">→</span>
|
||||
<span className="ins-acct">{tx.suggestedAccount || <span className="muted">No suggestion · tier 3 unmatched</span>}</span>
|
||||
</div>
|
||||
<div className="ins-tier-line"><TierBadge tier={tx.tier} /> · matched by {tx.tier === "rules" ? "pattern rule" : tx.tier === "llm" ? "LLM (gpt-4o-mini)" : tx.tier === "agent" ? "agent · 3 tool calls" : "no tier"}</div>
|
||||
</div>
|
||||
|
||||
<div className="ins-section">
|
||||
<div className="ins-eyebrow"><span>CATEGORIZE TO</span></div>
|
||||
<AccountPicker value={account} onChange={setAccount} mru={mru} />
|
||||
</div>
|
||||
|
||||
<div className="ins-section">
|
||||
<div className="ins-eyebrow"><span>LEDGER PREVIEW</span></div>
|
||||
<LedgerPreview tx={tx} account={account} />
|
||||
</div>
|
||||
|
||||
<div className="ins-actions">
|
||||
<Btn variant="primary" onClick={() => onApprove(tx.id, account)}>
|
||||
<Icon name="check" size={13} /> APPROVE
|
||||
</Btn>
|
||||
<Btn>OVERRIDE</Btn>
|
||||
<Btn onClick={() => onSkip(tx.id)}>SKIP</Btn>
|
||||
<span className="ins-kbd">⏎</span>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { Inspector, LedgerPreview, AccountPicker });
|
||||
86
ui_kits/web/LedgerScreen.jsx
Normal file
86
ui_kits/web/LedgerScreen.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// LedgerScreen — per-entity account tree with balances
|
||||
|
||||
function TreeNode({ node, depth, expanded, onToggle }) {
|
||||
const indent = depth * 18;
|
||||
const isBranch = node.kind === "branch";
|
||||
return (
|
||||
<div className={`tree-node ${isBranch ? "branch" : "leaf"}`}
|
||||
onClick={() => isBranch && onToggle(node.path)}>
|
||||
<span style={{ width: indent }}></span>
|
||||
<span className="tree-chevron">
|
||||
{isBranch ? <Icon name={expanded ? "chevDown" : "chevRight"} size={12} color="var(--fg-muted)"/> : <span className="dot"></span>}
|
||||
</span>
|
||||
<span className="tree-path">
|
||||
{node.path.split(":").slice(-1)[0]}
|
||||
</span>
|
||||
<span className="tree-full">{node.path}</span>
|
||||
<span className="tree-amount"><Amount value={node.balance} ccy={node.ccy} /></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LedgerScreen({ entity }) {
|
||||
const data = window.AKEFIN_DATA.accountsByEntity;
|
||||
const [expanded, setExpanded] = React.useState(new Set(["Personal:Assets", "Personal:Expenses", "9TFox:Assets", "9TFox:Income"]));
|
||||
const toggle = (path) => setExpanded(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(path) ? next.delete(path) : next.add(path);
|
||||
return next;
|
||||
});
|
||||
|
||||
const entities = entity === "all" ? ["personal", "tfox", "finacode"] : [entity];
|
||||
|
||||
return (
|
||||
<div className="ledger-screen">
|
||||
<div className="screen-head">
|
||||
<div>
|
||||
<h1 className="screen-title">Ledger</h1>
|
||||
<div className="screen-sub">Read-only · synced from <code>~/akefin/ledger/</code> · git rev <code>e4a82c1</code></div>
|
||||
</div>
|
||||
<div className="screen-actions">
|
||||
<Btn><Icon name="refresh" size={13} /> PULL</Btn>
|
||||
<Btn variant="primary"><Icon name="import" size={13} /> EXPORT</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{entities.map(e => {
|
||||
const map = window.AKEFIN_DATA.entities.find(x => x.id === e);
|
||||
const accounts = data[e] || [];
|
||||
return (
|
||||
<div key={e} className="ledger-entity">
|
||||
<div className="ledger-entity-head">
|
||||
<span className="stripe" style={{ background: map.color }}></span>
|
||||
<span className="ledger-entity-name">{map.label}</span>
|
||||
<span className="ledger-entity-meta">{accounts.length} accounts · {e}-2026-03.ldgr</span>
|
||||
</div>
|
||||
<div className="tree">
|
||||
<div className="tree-head">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span>ACCOUNT</span>
|
||||
<span>FULL PATH</span>
|
||||
<span style={{textAlign:"right"}}>BALANCE</span>
|
||||
</div>
|
||||
{accounts.map(node => {
|
||||
if (node.kind === "leaf") {
|
||||
const parent = node.path.split(":").slice(0, -1).join(":");
|
||||
if (parent && parent.includes(":") && !expanded.has(parent.split(":").slice(0,2).join(":"))) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const depth = node.path.split(":").length - 1;
|
||||
return (
|
||||
<TreeNode key={node.path} node={node} depth={depth}
|
||||
expanded={expanded.has(node.path)}
|
||||
onToggle={toggle} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { LedgerScreen });
|
||||
32
ui_kits/web/README.md
Normal file
32
ui_kits/web/README.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Akefin Web Dashboard — UI Kit
|
||||
|
||||
A pixel-faithful recreation of the **Akefin web dashboard** following the design system in this project. Built from the brief's component inventory (the canonical akefin-design-system repo was empty at the time of generation — see root `README.md`).
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `index.html` | Live, clickable prototype. Open this to see it. |
|
||||
| `App.jsx` | Top-level composition · routes between Review · Rules · Ledger · Import |
|
||||
| `Chrome.jsx` | Header (entity scope · search · status) + left rail navigation |
|
||||
| `ReviewQueue.jsx` | Compact list of staged transactions, sortable, selectable |
|
||||
| `Inspector.jsx` | Right-pane detail: account picker, ledger preview, approve/override/skip |
|
||||
| `RulesScreen.jsx` | Promote-suggested-rules view |
|
||||
| `LedgerScreen.jsx` | Per-entity account tree with balances |
|
||||
| `ImportScreen.jsx` | Import run status & history |
|
||||
| `Atoms.jsx` | Buttons, chips, badges, inputs, terminal frame |
|
||||
| `data.js` | Sample bilingual transactions, rules, accounts |
|
||||
|
||||
## Screens
|
||||
|
||||
1. **Review queue** *(default)* — list of staged transactions on the left, inspector on the right.
|
||||
2. **Rules** — promote-suggested-rule cards.
|
||||
3. **Ledger** — account tree.
|
||||
4. **Import** — recent import runs and status.
|
||||
|
||||
Switch screens via the left rail. The entity scope (top-left of the header) filters everything below.
|
||||
|
||||
## Caveats
|
||||
|
||||
- Recreated from the brief, not from existing UI source. **Iterate.**
|
||||
- Korean payee strings are sample data, not a real Toss export.
|
||||
125
ui_kits/web/ReviewQueue.jsx
Normal file
125
ui_kits/web/ReviewQueue.jsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// ReviewQueue — compact list of staged transactions
|
||||
|
||||
function ReviewQueue({ entity, transactions, selectedId, onSelect, sort, setSort, filters, setFilters, onOpenPalette }) {
|
||||
const [filterOpen, setFilterOpen] = React.useState(false);
|
||||
|
||||
// Apply filters
|
||||
const cutoffDays = { today: 0, "7d": 7, "30d": 30, "this-month": 30 };
|
||||
const filtered = transactions.filter(t => {
|
||||
if (entity !== "all" && t.entity !== entity) return false;
|
||||
// Date
|
||||
if (filters.dateRange !== "all" && filters.dateRange !== "custom") {
|
||||
const days = cutoffDays[filters.dateRange];
|
||||
const cutoff = new Date("2026-03-08"); // anchor "now"
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
if (new Date(t.date) < cutoff) return false;
|
||||
} else if (filters.dateRange === "custom") {
|
||||
if (filters.dateFrom && t.date < filters.dateFrom) return false;
|
||||
if (filters.dateTo && t.date > filters.dateTo) return false;
|
||||
}
|
||||
// Categories
|
||||
if (filters.categories.size > 0) {
|
||||
const cat = (t.suggestedAccount || "").split(":").slice(2).join(":");
|
||||
const hit = [...filters.categories].some(c => cat.includes(c));
|
||||
if (!hit) return false;
|
||||
}
|
||||
// Tiers
|
||||
if (filters.tiers.size > 0 && !filters.tiers.has(t.tier)) return false;
|
||||
// Confidence
|
||||
if ((t.score ?? 0) < filters.confidenceMin) return false;
|
||||
// Currency
|
||||
if (filters.ccy.size > 0 && !filters.ccy.has(t.ccy)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const activeCount =
|
||||
(filters.dateRange !== "all" ? 1 : 0) +
|
||||
(filters.categories.size > 0 ? 1 : 0) +
|
||||
(filters.tiers.size > 0 ? 1 : 0) +
|
||||
(filters.ccy.size > 0 ? 1 : 0) +
|
||||
(filters.confidenceMin > 0 ? 1 : 0);
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
if (sort === "date-desc") return b.date.localeCompare(a.date);
|
||||
if (sort === "date-asc") return a.date.localeCompare(b.date);
|
||||
if (sort === "conf-asc") return (a.score ?? -1) - (b.score ?? -1);
|
||||
if (sort === "conf-desc") return (b.score ?? -1) - (a.score ?? -1);
|
||||
if (sort === "entity") return a.entity.localeCompare(b.entity);
|
||||
return 0;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="review-pane">
|
||||
<div className="rq-head">
|
||||
<div className="rq-tabs">
|
||||
<button className="rq-tab active">Staged <span className="rq-count">{filtered.length}</span></button>
|
||||
<button className="rq-tab">Approved <span className="rq-count">312</span></button>
|
||||
<button className="rq-tab">Skipped <span className="rq-count">4</span></button>
|
||||
</div>
|
||||
<div className="rq-controls">
|
||||
<button className={`rq-ctrl ${activeCount > 0 ? "active" : ""}`} onClick={() => setFilterOpen(o => !o)}>
|
||||
<Icon name="filter" size={13} /> FILTER
|
||||
{activeCount > 0 && <span className="rq-ctrl-dot">{activeCount}</span>}
|
||||
</button>
|
||||
<button className="rq-ctrl" onClick={onOpenPalette}>
|
||||
<Icon name="spark" size={13} /> ⌘K
|
||||
</button>
|
||||
<div className="rq-sort">
|
||||
<Icon name="sort" size={13} />
|
||||
<select value={sort} onChange={e => setSort(e.target.value)}>
|
||||
<option value="date-desc">Date · newest</option>
|
||||
<option value="date-asc">Date · oldest</option>
|
||||
<option value="conf-asc">Confidence · lowest</option>
|
||||
<option value="conf-desc">Confidence · highest</option>
|
||||
<option value="entity">Entity</option>
|
||||
</select>
|
||||
<Icon name="chevDown" size={12} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FilterPanel open={filterOpen} filters={filters} setFilters={setFilters}
|
||||
onClose={() => setFilterOpen(false)} activeCount={activeCount} />
|
||||
|
||||
<div className="rq-list-head">
|
||||
<span>DATE</span>
|
||||
<span>PAYEE</span>
|
||||
<span style={{textAlign:"right"}}>AMOUNT</span>
|
||||
<span>ENTITY</span>
|
||||
<span>CONFIDENCE</span>
|
||||
<span>TIER</span>
|
||||
</div>
|
||||
|
||||
<div className="rq-list">
|
||||
{sorted.map(t => (
|
||||
<button key={t.id}
|
||||
className={`tx-row ${selectedId === t.id ? "selected" : ""}`}
|
||||
onClick={() => onSelect(t.id)}>
|
||||
<span className="tx-date">{t.date.slice(5)}</span>
|
||||
<span className="tx-payee">
|
||||
<span className="ko">{t.payee}</span>
|
||||
{t.payeeNote && <span className="tx-payee-note">{t.payeeNote}</span>}
|
||||
</span>
|
||||
<span className="tx-amount-cell"><Amount value={t.amount} ccy={t.ccy} /></span>
|
||||
<span><EntityBadge entity={t.entity} /></span>
|
||||
<span><ConfidenceChip score={t.score} tier={t.tier} /></span>
|
||||
<span><TierBadge tier={t.tier} /></span>
|
||||
</button>
|
||||
))}
|
||||
{sorted.length === 0 && (
|
||||
<div className="rq-empty">
|
||||
<div className="rq-empty-title">No transactions match</div>
|
||||
<div className="rq-empty-sub">Clear filters or expand the date range</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rq-foot">
|
||||
<span className="rq-foot-meta">{sorted.length} of {transactions.filter(t => entity === "all" || t.entity === entity).length} · sorted by {sort.replace("-", " ")}{activeCount > 0 ? ` · ${activeCount} filter${activeCount > 1 ? "s" : ""}` : ""}</span>
|
||||
<span className="rq-foot-meta">⏎ approve · ⌫ skip · ⌘E override · ⌘K commands</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { ReviewQueue });
|
||||
86
ui_kits/web/RulesScreen.jsx
Normal file
86
ui_kits/web/RulesScreen.jsx
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
// RulesScreen — promote-suggested-rules view
|
||||
|
||||
function RuleCard({ rule, checked, onToggle, onApply }) {
|
||||
return (
|
||||
<div className="rule-card">
|
||||
<div className="rule-head">
|
||||
<div className="rule-pattern">
|
||||
<span className="pattern-prefix">payee ~=</span>
|
||||
<code className="ko">"{rule.pattern}"</code>
|
||||
</div>
|
||||
<div className="rule-occ">
|
||||
<span className="rule-num">{rule.occurrences}</span>
|
||||
<span className="rule-occ-lbl">OCCURRENCES · LAST 30d</span>
|
||||
</div>
|
||||
<ConfidenceChip score={rule.score} tier="llm" />
|
||||
</div>
|
||||
<div className="rule-map">
|
||||
<span className="ko">{rule.pattern}</span>
|
||||
<Icon name="arrowRight" size={14} color="var(--fg-muted)" />
|
||||
<span className="rule-target">{rule.target}</span>
|
||||
</div>
|
||||
<div className="rule-examples">
|
||||
<div className="rule-ex-label">MATCHING TRANSACTIONS</div>
|
||||
{rule.examples.map((ex, i) => <div key={i} className="rule-ex">{ex}</div>)}
|
||||
</div>
|
||||
<div className="rule-actions">
|
||||
<label className="rule-promote">
|
||||
<input type="checkbox" checked={checked} onChange={onToggle} />
|
||||
<span>Promote on next run</span>
|
||||
</label>
|
||||
<div style={{ flex: 1 }}></div>
|
||||
<Btn variant="primary" onClick={onApply}>APPLY RULE</Btn>
|
||||
<Btn>DISMISS</Btn>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RulesScreen({ entity }) {
|
||||
const data = window.AKEFIN_DATA.ruleSuggestions;
|
||||
const [promoted, setPromoted] = React.useState(new Set(data.map(r => r.id)));
|
||||
const [applied, setApplied] = React.useState(new Set());
|
||||
|
||||
const toggle = (id) => {
|
||||
setPromoted(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const apply = (id) => setApplied(prev => new Set([...prev, id]));
|
||||
|
||||
const visible = data.filter(r => !applied.has(r.id));
|
||||
|
||||
return (
|
||||
<div className="rules-screen">
|
||||
<div className="screen-head">
|
||||
<div>
|
||||
<h1 className="screen-title">Rule suggestions</h1>
|
||||
<div className="screen-sub">{visible.length} suggested · {promoted.size} promoted on next run · last AI pass 12 minutes ago</div>
|
||||
</div>
|
||||
<div className="screen-actions">
|
||||
<Btn><Icon name="refresh" size={13} /> RE-RUN AI PASS</Btn>
|
||||
<Btn variant="primary">PROMOTE ALL</Btn>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TermFrame title="AKEFIN — RULE PIPELINE · TIER 1" status="ok">
|
||||
<span className="m">$</span> akefin rules:suggest --entity {entity === "all" ? "*" : entity} --since 30d{"\n"}
|
||||
<span className="m">analysed 1,247 transactions · 5 high-frequency patterns matched ·</span>
|
||||
<span style={{color:"var(--conf-rules)"}}> 218 future rows would auto-categorize</span>
|
||||
</TermFrame>
|
||||
|
||||
<div className="rule-grid">
|
||||
{visible.map(r => (
|
||||
<RuleCard key={r.id} rule={r}
|
||||
checked={promoted.has(r.id)}
|
||||
onToggle={() => toggle(r.id)}
|
||||
onApply={() => apply(r.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Object.assign(window, { RulesScreen });
|
||||
1577
ui_kits/web/dashboard.css
Normal file
1577
ui_kits/web/dashboard.css
Normal file
File diff suppressed because it is too large
Load diff
114
ui_kits/web/data.js
Normal file
114
ui_kits/web/data.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// Sample bilingual data for the Akefin web dashboard prototype.
|
||||
|
||||
window.AKEFIN_DATA = (() => {
|
||||
const transactions = [
|
||||
{ id: "t01", date: "2026-03-02", payee: "스타벅스 강남역점", payeeNote: "Starbucks Gangnam", amount: -38500, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Food:Coffee", score: 0.94, tier: "llm", sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
{ id: "t02", date: "2026-03-02", payee: "이마트 트레이더스", payeeNote: "E-Mart Traders", amount: -124300, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Groceries", score: 0.78, tier: "agent", sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
{ id: "t03", date: "2026-03-03", payee: "Wise · Finacode partner", payeeNote: null, amount: 1240, ccy: "EUR", entity: "finacode", suggestedAccount: null, score: null, tier: "unmatched",sourceAccount: "Wise · personal", status: "staged" },
|
||||
{ id: "t04", date: "2026-03-03", payee: "9TFox 컨설팅 — 클라이언트 A", payeeNote: "Consulting income", amount: 2400000, ccy: "KRW", entity: "tfox", suggestedAccount: "9TFox:Income:Consulting", score: 1.00, tier: "rules", sourceAccount: "Kakao Bank · 9TFox", status: "staged" },
|
||||
{ id: "t05", date: "2026-03-04", payee: "GS25 역삼점", payeeNote: "GS25 convenience", amount: -4800, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Convenience", score: 0.92, tier: "llm", sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
{ id: "t06", date: "2026-03-04", payee: "관리비 — 서초 오피스텔", payeeNote: "Apartment maintenance", amount: -286000, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Housing:Maint", score: 0.71, tier: "agent", sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
{ id: "t07", date: "2026-03-05", payee: "Migros · İstanbul Beşiktaş", payeeNote: null, amount: -892, ccy: "TRY", entity: "personal", suggestedAccount: "Personal:Expenses:Groceries", score: 0.88, tier: "llm", sourceAccount: "Garanti BBVA", status: "staged" },
|
||||
{ id: "t08", date: "2026-03-05", payee: "JR 동일본 — Suica 충전", payeeNote: "JR East · Suica", amount: -3000, ccy: "JPY", entity: "personal", suggestedAccount: "Personal:Expenses:Transit", score: 0.81, tier: "llm", sourceAccount: "Wise · multi-currency", status: "staged" },
|
||||
{ id: "t09", date: "2026-03-06", payee: "배달의민족", payeeNote: "Baemin · food delivery", amount: -22600, ccy: "KRW", entity: "personal", suggestedAccount: "Personal:Expenses:Food:Delivery", score: 0.96, tier: "rules", sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
{ id: "t10", date: "2026-03-06", payee: "노션 — 월 구독", payeeNote: "Notion subscription", amount: -16000, ccy: "KRW", entity: "tfox", suggestedAccount: "9TFox:Expenses:Software", score: 0.97, tier: "rules", sourceAccount: "Kakao Bank · 9TFox", status: "staged" },
|
||||
{ id: "t11", date: "2026-03-07", payee: "MAVI Jeans · Levent", payeeNote: null, amount: -1840, ccy: "TRY", entity: "personal", suggestedAccount: "Personal:Expenses:Clothing", score: 0.68, tier: "agent", sourceAccount: "Garanti BBVA", status: "staged" },
|
||||
{ id: "t12", date: "2026-03-07", payee: "Hetzner Online GmbH", payeeNote: "Server hosting", amount: -29.40, ccy: "EUR", entity: "tfox", suggestedAccount: "9TFox:Expenses:Infrastructure", score: 0.99, tier: "rules", sourceAccount: "Wise · 9TFox", status: "staged" },
|
||||
{ id: "t13", date: "2026-03-08", payee: "ATM 출금 — 강남역", payeeNote: "Cash withdrawal", amount: -100000, ccy: "KRW", entity: "personal", suggestedAccount: null, score: 0.42, tier: "unmatched",sourceAccount: "Toss · 신한 입출금", status: "staged" },
|
||||
];
|
||||
|
||||
const ruleSuggestions = [
|
||||
{ id: "r01", pattern: '스타벅스 *', target: "Personal:Expenses:Food:Coffee", occurrences: 14, score: 0.97, examples: ["2026-03-02 스타벅스 강남역점 −38,500 KRW", "2026-02-28 스타벅스 판교점 −6,300 KRW", "2026-02-24 스타벅스 양재점 −7,100 KRW"] },
|
||||
{ id: "r02", pattern: '배달의민족', target: "Personal:Expenses:Food:Delivery", occurrences: 22, score: 0.99, examples: ["2026-03-06 배달의민족 −22,600 KRW", "2026-02-19 배달의민족 −18,400 KRW"] },
|
||||
{ id: "r03", pattern: 'Hetzner *', target: "9TFox:Expenses:Infrastructure", occurrences: 6, score: 0.98, examples: ["2026-03-07 Hetzner Online GmbH −29.40 EUR", "2026-02-07 Hetzner Online GmbH −29.40 EUR"] },
|
||||
{ id: "r04", pattern: 'GS25 *', target: "Personal:Expenses:Convenience", occurrences: 19, score: 0.88, examples: ["2026-03-04 GS25 역삼점 −4,800 KRW", "2026-03-01 GS25 강남점 −3,200 KRW"] },
|
||||
{ id: "r05", pattern: 'Wise · *partner', target: "Finacode:Income:PartnerShare", occurrences: 3, score: 0.74, examples: ["2026-03-03 Wise · Finacode partner +1,240 EUR", "2026-02-03 Wise · Finacode partner +1,180 EUR"] },
|
||||
];
|
||||
|
||||
const accountsByEntity = {
|
||||
personal: [
|
||||
{ path: "Personal:Assets", balance: 24830000, ccy: "KRW", kind: "branch" },
|
||||
{ path: "Personal:Assets:Bank:Toss", balance: 14200000, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "Personal:Assets:Bank:Garanti", balance: 62400, ccy: "TRY", kind: "leaf" },
|
||||
{ path: "Personal:Assets:Bank:Wise", balance: 2840, ccy: "EUR", kind: "leaf" },
|
||||
{ path: "Personal:Expenses", balance: -942000, ccy: "KRW", kind: "branch" },
|
||||
{ path: "Personal:Expenses:Food:Coffee", balance: -84600, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "Personal:Expenses:Food:Delivery", balance: -224000, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "Personal:Expenses:Groceries", balance: -481600, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "Personal:Expenses:Transit", balance: -38000, ccy: "KRW", kind: "leaf" },
|
||||
],
|
||||
tfox: [
|
||||
{ path: "9TFox:Assets", balance: 18400000, ccy: "KRW", kind: "branch" },
|
||||
{ path: "9TFox:Assets:Bank:Kakao", balance: 18400000, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "9TFox:Income:Consulting", balance: -7200000, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "9TFox:Expenses:Software", balance: 96000, ccy: "KRW", kind: "leaf" },
|
||||
{ path: "9TFox:Expenses:Infrastructure", balance: 176.4, ccy: "EUR", kind: "leaf" },
|
||||
],
|
||||
finacode: [
|
||||
{ path: "Finacode:Assets", balance: 6320, ccy: "EUR", kind: "branch" },
|
||||
{ path: "Finacode:Income:PartnerShare", balance: -3700, ccy: "EUR", kind: "leaf" },
|
||||
],
|
||||
};
|
||||
|
||||
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" },
|
||||
{ id: "i03", at: "2026-03-07 12:48", source: "Wise · 9TFox", rows: 14, auto: 12, high: 2, review: 0, failed: 0, entity: "tfox" },
|
||||
{ id: "i04", at: "2026-03-06 22:01", source: "Garanti BBVA", rows: 89, auto: 61, high: 14, review: 11, failed: 3, entity: "personal" },
|
||||
];
|
||||
|
||||
const entities = [
|
||||
{ id: "all", label: "All entities", short: "ALL", short2: "—" },
|
||||
{ id: "personal", label: "Personal", short: "P/", color: "#3D6E70" },
|
||||
{ id: "tfox", label: "9TFox", short: "9T/", color: "#B4541A" },
|
||||
{ id: "finacode", label: "Finacode", short: "FC/", color: "#5A4FA3" },
|
||||
];
|
||||
|
||||
// Common chart-of-accounts for the picker (sorted)
|
||||
const allAccounts = [
|
||||
...accountsByEntity.personal, ...accountsByEntity.tfox, ...accountsByEntity.finacode
|
||||
].map(a => a.path).sort();
|
||||
|
||||
// FX rates (against KRW, the home currency) — sampled rates as of 2026-03-08
|
||||
const fx = {
|
||||
KRW: 1,
|
||||
EUR: 1456.20,
|
||||
TRY: 41.32,
|
||||
JPY: 9.21,
|
||||
USD: 1339.50,
|
||||
};
|
||||
const convert = (amount, fromCcy, toCcy) => {
|
||||
if (fromCcy === toCcy) return amount;
|
||||
return (amount * fx[fromCcy]) / fx[toCcy];
|
||||
};
|
||||
|
||||
// Commands for the ⌘K palette
|
||||
const commands = [
|
||||
{ id: "approve", label: "Approve current", kind: "action", hint: "⏎", icon: "check" },
|
||||
{ id: "override", label: "Override category", kind: "action", hint: "⌘E", icon: "rule" },
|
||||
{ id: "skip", label: "Skip and re-queue", kind: "action", hint: "⌫", icon: "x" },
|
||||
{ id: "approve-all-high", label: "Approve all ≥ 0.85 confidence", kind: "batch", hint: "⌘⇧A", icon: "check" },
|
||||
{ id: "import-csv", label: "Import Toss CSV…", kind: "action", hint: "⌘I", icon: "import" },
|
||||
{ id: "poll-toss", label: "Poll Toss for new transactions", kind: "action", hint: "⌘⇧I", icon: "refresh" },
|
||||
{ id: "promote-rules", label: "Promote suggested rules", kind: "action", hint: "⌘R", icon: "rule" },
|
||||
{ id: "commit-ledger", label: "Commit ledger to git", kind: "action", hint: "⌘⇧G", icon: "ledger" },
|
||||
{ id: "switch-personal", label: "Switch scope: Personal", kind: "nav", hint: "⌘1", icon: "card" },
|
||||
{ id: "switch-9tfox", label: "Switch scope: 9TFox", kind: "nav", hint: "⌘2", icon: "card" },
|
||||
{ id: "switch-finacode", label: "Switch scope: Finacode", kind: "nav", hint: "⌘3", icon: "card" },
|
||||
{ id: "go-review", label: "Go to Review queue", kind: "nav", hint: "G R", icon: "activity" },
|
||||
{ id: "go-rules", label: "Go to Rules", kind: "nav", hint: "G U", icon: "rule" },
|
||||
{ id: "go-ledger", label: "Go to Ledger", kind: "nav", hint: "G L", icon: "ledger" },
|
||||
{ id: "go-import", label: "Go to Import status", kind: "nav", hint: "G I", icon: "import" },
|
||||
{ id: "toggle-theme", label: "Toggle dark theme", kind: "setting", hint: "⌘\\", icon: "card" },
|
||||
{ id: "search-payee", label: "Search payee or 적요…", kind: "search", hint: "/", icon: "search" },
|
||||
];
|
||||
|
||||
// Categories (for filter)
|
||||
const categories = [
|
||||
"Food:Coffee", "Food:Delivery", "Food:Restaurant", "Groceries", "Convenience",
|
||||
"Transit", "Housing:Maint", "Clothing", "Software", "Infrastructure",
|
||||
"Income:Consulting", "Income:PartnerShare", "Cash",
|
||||
];
|
||||
|
||||
return { transactions, ruleSuggestions, accountsByEntity, importRuns, entities, allAccounts, fx, convert, commands, categories };
|
||||
})();
|
||||
29
ui_kits/web/index.html
Normal file
29
ui_kits/web/index.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=1440">
|
||||
<title>Akefin — Review Queue</title>
|
||||
<link rel="icon" href="../../assets/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="../../colors_and_type.css">
|
||||
<link rel="stylesheet" href="dashboard.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||
|
||||
<script src="data.js"></script>
|
||||
<script type="text/babel" src="Atoms.jsx"></script>
|
||||
<script type="text/babel" src="Chrome.jsx"></script>
|
||||
<script type="text/babel" src="CommandFilter.jsx"></script>
|
||||
<script type="text/babel" src="ReviewQueue.jsx"></script>
|
||||
<script type="text/babel" src="Inspector.jsx"></script>
|
||||
<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="App.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue