Filter transactions by classification status

The Transactions toolbar gains a status filter over classification
provenance — manual, AI, merchant rule, transfer match, unclassified —
alongside the existing Needs review toggle. The Source column now
renders the same human labels the filter options use instead of raw
provenance keys, so "openrouter" reads as AI and both fallback shapes
read as Unclassified.
This commit is contained in:
Lars Nolden
2026-09-14 12:39:11 +02:00
parent 16daa01647
commit 0fd3c5c0dc
4 changed files with 60 additions and 4 deletions
+46 -2
View File
@@ -41,6 +41,26 @@ const EVENTS: Record<string, string> = {
corporate_action: "Corporate action",
position_transfer: "Position transfer",
};
// Human labels for classification provenance sources; the filter options and
// the Source column speak the same language. Both fallback shapes — the
// import-time "unclassified" error record and the plain sign-based
// "fallback" — read as Unclassified.
const CLASSIFICATIONS: Record<string, string> = {
manual: "Manual",
openrouter: "AI",
rule: "Merchant rule",
transfer_match: "Transfer match",
fallback: "Unclassified",
unclassified: "Unclassified",
};
const CLASSIFICATION_FILTERS: [string, string][] = [
["manual", "Manual"],
["openrouter", "AI"],
["rule", "Merchant rule"],
["transfer_match", "Transfer match"],
["unclassified", "Unclassified"],
];
// A corporate action or a position transfer moves shares between holdings and
// settles no money at all, so its zero amount is a fact and not a gap.
function positionOnly(investment?: Investment): boolean {
@@ -92,6 +112,7 @@ export function Transactions({
}) {
const [query, setQuery] = useState("");
const [needsReview, setNeedsReview] = useState(false);
const [status, setStatus] = useState("");
const [editing, setEditing] = useState<Transaction | null>(null);
const [page, setPage] = useState(0);
const filtered = useMemo(() => {
@@ -122,6 +143,12 @@ export function Transactions({
(e.kind === "income"
? "cat_income_unclassified"
: "cat_expenses_unclassified")) &&
(!status ||
(status === "unclassified"
? ["fallback", "unclassified", ""].includes(
e.classification.source || "",
)
: e.classification.source === status)) &&
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
(!query ||
@@ -134,7 +161,7 @@ export function Transactions({
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
a.facts.id.localeCompare(b.facts.id),
);
}, [data, filter, query, needsReview]);
}, [data, filter, query, needsReview, status]);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(filtered.length / 40) - 1),
@@ -181,6 +208,22 @@ export function Transactions({
/>
Needs review
</label>
<select
className="toolbar-select"
aria-label="Classification status"
value={status}
onChange={(e) => {
setStatus(e.target.value);
setPage(0);
}}
>
<option value="">All classifications</option>
{CLASSIFICATION_FILTERS.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<span className="muted small">
<SlidersHorizontal size={15} /> Click a transaction to edit
</span>
@@ -284,7 +327,8 @@ export function Transactions({
</td>
<td>
<span className="badge neutral">
{e.classification.source}
{CLASSIFICATIONS[e.classification.source] ??
e.classification.source}
</span>
{e.classification.error && (
<small className="text-danger">
+10
View File
@@ -744,6 +744,16 @@ main {
.search input::placeholder {
color: #9aa6b3;
}
.toolbar-select {
height: 35px;
font-size: 11px;
padding: 0 9px;
border: 1px solid #dbe2ea;
border-radius: 5px;
background: #fff;
color: #46596a;
flex-shrink: 0;
}
.table-scroll {
overflow-x: auto;
}