311 lines
15 KiB
JavaScript
311 lines
15 KiB
JavaScript
// Mobile screens — Queue, Detail, Picker (sheet), Import, Ledger
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// Header — universal mobile top bar
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MHeader({ left, title, right, eyebrow }) {
|
|
return (
|
|
<div className="m-header">
|
|
<div className="m-header-row">
|
|
<div className="m-header-left">{left}</div>
|
|
<div className="m-header-right">{right}</div>
|
|
</div>
|
|
{eyebrow && <div className="m-header-eyebrow">{eyebrow}</div>}
|
|
{title && <div className="m-header-title">{title}</div>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Tab bar
|
|
function MTabBar({ active, onChange }) {
|
|
const tabs = [
|
|
{ id: "queue", icon: "activity", label: "Review" },
|
|
{ id: "rules", icon: "rule", label: "Rules" },
|
|
{ id: "ledger", icon: "ledger", label: "Ledger" },
|
|
{ id: "import", icon: "import", label: "Import" },
|
|
];
|
|
return (
|
|
<div className="m-tabbar">
|
|
{tabs.map(t => (
|
|
<button key={t.id} className={`m-tab ${active === t.id ? "active" : ""}`}
|
|
onClick={() => onChange(t.id)}>
|
|
<MIcon name={t.icon} size={18} />
|
|
<span>{t.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 1. QUEUE SCREEN — list of staged transactions
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MQueueScreen({ transactions, entity, onTap }) {
|
|
const filtered = transactions.filter(t => entity === "all" || t.entity === entity);
|
|
return (
|
|
<div className="m-screen">
|
|
<MHeader
|
|
eyebrow="REVIEW QUEUE"
|
|
title="Review"
|
|
left={<button className="m-icon-btn"><MIcon name="filter" /></button>}
|
|
right={<button className="m-icon-btn"><MIcon name="search" /></button>}
|
|
/>
|
|
<div className="m-scope-strip">
|
|
<span className="m-stripe" style={{
|
|
background: entity === "personal" ? "#3D6E70" :
|
|
entity === "tfox" ? "#B4541A" :
|
|
entity === "finacode" ? "#5A4FA3" : "#1A1814"
|
|
}}></span>
|
|
<span className="m-scope-lbl">SCOPE</span>
|
|
<span className="m-scope-name">{entity === "all" ? "All entities" : entity === "personal" ? "Personal" : entity === "tfox" ? "9TFox" : "Finacode"}</span>
|
|
<span className="m-scope-count">{filtered.length} STAGED</span>
|
|
</div>
|
|
<div className="m-list">
|
|
{filtered.map(t => (
|
|
<button key={t.id} className="m-tx-row" onClick={() => onTap(t.id)}>
|
|
<div className="m-tx-top">
|
|
<span className="m-tx-date">{t.date.slice(5)}</span>
|
|
<MEntityBadge entity={t.entity} />
|
|
<MAmount value={t.amount} ccy={t.ccy} />
|
|
</div>
|
|
<div className="m-tx-payee ko">{t.payee}</div>
|
|
<div className="m-tx-bottom">
|
|
<MConfidenceChip score={t.score} tier={t.tier} />
|
|
<MTierBadge tier={t.tier} />
|
|
{t.suggestedAccount && (
|
|
<span className="m-tx-suggest">→ <span className="mono">{t.suggestedAccount.split(":").slice(-2).join(":")}</span></span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 2. DETAIL SCREEN — full review for one transaction
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MDetailScreen({ tx, onBack, onApprove, onSkip, onOpenPicker, account }) {
|
|
if (!tx) return null;
|
|
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).toLocaleString();
|
|
return (
|
|
<div className="m-screen">
|
|
<MHeader
|
|
eyebrow={`TRANSACTION · ${tx.id.toUpperCase()}`}
|
|
left={<button className="m-icon-btn" onClick={onBack}><MIcon name="chevLeft" /></button>}
|
|
right={<button className="m-icon-btn"><MIcon name="more" /></button>}
|
|
/>
|
|
<div className="m-detail-hero">
|
|
<div className="m-detail-payee ko">{tx.payee}</div>
|
|
{tx.payeeNote && <div className="m-detail-note">{tx.payeeNote}</div>}
|
|
<div className="m-detail-amount"><MAmount value={tx.amount} ccy={tx.ccy} big /></div>
|
|
<div className="m-detail-meta">
|
|
<span className="mono">{tx.date}</span>
|
|
<span className="dot">·</span>
|
|
<MEntityBadge entity={tx.entity} />
|
|
<span className="dot">·</span>
|
|
<span className="m-source ko">{tx.sourceAccount}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="m-section">
|
|
<div className="m-section-head"><span>AI SUGGESTION</span><MConfidenceChip score={tx.score} tier={tx.tier} /></div>
|
|
<div className="m-suggestion">
|
|
<span className="m-arrow">→</span>
|
|
<span className="m-acct-text">{tx.suggestedAccount || <span className="muted">No suggestion · unmatched</span>}</span>
|
|
</div>
|
|
<div className="m-tier-row">
|
|
<MTierBadge tier={tx.tier} />
|
|
<span className="m-tier-text">matched by {tx.tier === "rules" ? "pattern rule" : tx.tier === "llm" ? "LLM (gpt-4o-mini)" : tx.tier === "agent" ? "agent · 3 tool calls" : "no tier — needs override"}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="m-section">
|
|
<div className="m-section-head"><span>CATEGORIZE TO</span></div>
|
|
<button className="m-picker-trigger" onClick={onOpenPicker}>
|
|
<span className="m-acct-path mono">
|
|
{account ? account : <span className="muted">Choose account…</span>}
|
|
</span>
|
|
<MIcon name="chevRight" size={14} color="var(--fg-muted)" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="m-section">
|
|
<div className="m-section-head"><span>LEDGER PREVIEW</span></div>
|
|
{account ? (
|
|
<div className="m-ledger-preview">
|
|
<div className="m-ledger-row">
|
|
<span className="m-ledger-acct">{debit}</span>
|
|
<span className="m-ledger-amt"> {abs}</span>
|
|
<span className="m-ledger-ccy">{tx.ccy}</span>
|
|
</div>
|
|
<div className="m-ledger-row">
|
|
<span className="m-ledger-acct">{credit}</span>
|
|
<span className="m-ledger-amt">-{abs}</span>
|
|
<span className="m-ledger-ccy">{tx.ccy}</span>
|
|
</div>
|
|
<div className="m-ledger-foot">balanced · 2 legs</div>
|
|
</div>
|
|
) : (
|
|
<div className="m-ledger-empty">Choose an account to preview the posting</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="m-detail-actions">
|
|
<button className="m-action primary" onClick={onApprove}><MIcon name="check" size={16} /> APPROVE</button>
|
|
<button className="m-action ghost">OVERRIDE</button>
|
|
<button className="m-action ghost" onClick={onSkip}>SKIP</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 3. ACCOUNT PICKER — modal sheet
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MAccountPicker({ open, onClose, onPick, current }) {
|
|
const [q, setQ] = React.useState("");
|
|
if (!open) return null;
|
|
const accounts = window.AKEFIN_DATA.allAccounts;
|
|
const mru = ["Personal:Expenses:Food:Coffee", "Personal:Expenses:Groceries", "9TFox:Expenses:Software"];
|
|
const matches = q ? accounts.filter(a => a.toLowerCase().includes(q.toLowerCase())) : accounts;
|
|
|
|
return (
|
|
<div className="m-sheet-overlay" onClick={onClose}>
|
|
<div className="m-sheet" onClick={(e) => e.stopPropagation()}>
|
|
<div className="m-sheet-handle"></div>
|
|
<div className="m-sheet-head">
|
|
<button className="m-link" onClick={onClose}>Cancel</button>
|
|
<span className="m-sheet-title">Pick account</span>
|
|
<button className="m-link strong">Confirm</button>
|
|
</div>
|
|
<div className="m-sheet-search">
|
|
<MIcon name="search" size={14} color="var(--fg-muted)" />
|
|
<input autoFocus type="text" placeholder="Search accounts…" value={q} onChange={(e) => setQ(e.target.value)} />
|
|
</div>
|
|
{!q && (
|
|
<div className="m-sheet-section">
|
|
<div className="m-sheet-label">RECENT</div>
|
|
{mru.map(a => (
|
|
<button key={a} className="m-sheet-row" onClick={() => onPick(a)}>
|
|
<span className="mono">{a}</span>
|
|
<span className="m-mru">MRU</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="m-sheet-section">
|
|
<div className="m-sheet-label">{q ? "MATCHES" : "ALL ACCOUNTS"}</div>
|
|
{matches.map(a => (
|
|
<button key={a} className={`m-sheet-row ${a === current ? "active" : ""}`} onClick={() => onPick(a)}>
|
|
<span className="mono">{a}</span>
|
|
{a === current && <MIcon name="check" size={14} color="var(--conf-rules)" />}
|
|
</button>
|
|
))}
|
|
<button className="m-sheet-row create">
|
|
<MIcon name="plus" size={14} />
|
|
<span>Create new account…</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 4. IMPORT SCREEN
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MImportScreen({ entity }) {
|
|
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="m-screen">
|
|
<MHeader
|
|
eyebrow="STATUS"
|
|
title="Import"
|
|
right={<button className="m-icon-btn"><MIcon name="refresh" /></button>}
|
|
/>
|
|
<div className="m-stats">
|
|
<div className="m-stat"><span className="m-stat-num">{total.rows}</span><span className="m-stat-lbl">TOTAL</span></div>
|
|
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-rules)"}}>{total.auto}</span><span className="m-stat-lbl">AUTO</span></div>
|
|
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-mid)"}}>{total.review}</span><span className="m-stat-lbl">REVIEW</span></div>
|
|
<div className="m-stat"><span className="m-stat-num" style={{color: "var(--conf-low)"}}>{total.failed}</span><span className="m-stat-lbl">FAILED</span></div>
|
|
</div>
|
|
|
|
<div className="m-section-head" style={{ padding: "16px 16px 8px" }}><span>RECENT RUNS</span></div>
|
|
<div className="m-list">
|
|
{runs.map(r => (
|
|
<div key={r.id} className="m-import-row">
|
|
<div className="m-import-row-top">
|
|
<span className="m-import-when mono">{r.at}</span>
|
|
<MEntityBadge entity={r.entity} />
|
|
</div>
|
|
<div className="m-import-row-src ko">{r.source}</div>
|
|
<div className="m-import-bar">
|
|
<div style={{width: `${(r.auto/r.rows)*100}%`, background: "var(--conf-rules)"}}></div>
|
|
<div style={{width: `${(r.high/r.rows)*100}%`, background: "var(--conf-high)"}}></div>
|
|
<div style={{width: `${(r.review/r.rows)*100}%`, background: "var(--conf-mid)"}}></div>
|
|
<div style={{width: `${(r.failed/r.rows)*100}%`, background: "var(--conf-low)"}}></div>
|
|
</div>
|
|
<div className="m-import-row-meta">
|
|
<span className="mono">{r.rows} rows</span>
|
|
<span className="dot">·</span>
|
|
<span className="mono">{r.auto} auto</span>
|
|
<span className="dot">·</span>
|
|
<span className="mono">{r.review} review</span>
|
|
{r.failed > 0 && <><span className="dot">·</span><span className="mono" style={{color:"var(--conf-low)"}}>{r.failed} failed</span></>}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────────
|
|
// 5. LEDGER SCREEN
|
|
// ──────────────────────────────────────────────────────────────────
|
|
function MLedgerScreen({ entity }) {
|
|
const data = window.AKEFIN_DATA.accountsByEntity;
|
|
const entities = entity === "all" ? ["personal", "tfox", "finacode"] : [entity];
|
|
return (
|
|
<div className="m-screen">
|
|
<MHeader eyebrow="READ-ONLY · GIT-SYNCED" title="Ledger" />
|
|
{entities.map(e => {
|
|
const map = window.AKEFIN_DATA.entities.find(x => x.id === e);
|
|
const accounts = data[e] || [];
|
|
return (
|
|
<div key={e} className="m-ledger-entity">
|
|
<div className="m-ledger-entity-head">
|
|
<span className="m-stripe" style={{background: map.color}}></span>
|
|
<span className="m-ledger-entity-name">{map.label}</span>
|
|
<span className="m-ledger-entity-meta mono">{accounts.length} accts</span>
|
|
</div>
|
|
{accounts.map(a => (
|
|
<div key={a.path} className={`m-ledger-acct-row ${a.kind}`}>
|
|
<span className="mono">{a.path.split(":").slice(-1)[0]}</span>
|
|
<span className="m-ledger-acct-full mono">{a.path}</span>
|
|
<MAmount value={a.balance} ccy={a.ccy} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
Object.assign(window, {
|
|
MHeader, MTabBar, MQueueScreen, MDetailScreen, MAccountPicker, MImportScreen, MLedgerScreen,
|
|
});
|