125 lines
5.3 KiB
JavaScript
125 lines
5.3 KiB
JavaScript
// 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 });
|