akefin-design-system/ui_kits/mobile/MobileScreens.jsx
2026-05-23 11:59:45 +09:00

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,
});