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

190 lines
7.7 KiB
JavaScript

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