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:
+3
-1
@@ -172,7 +172,9 @@ response records high, medium or low confidence. Imports never auto-apply a
|
|||||||
low-confidence category: the row keeps the kind-specific unclassified
|
low-confidence category: the row keeps the kind-specific unclassified
|
||||||
category with merchant and confidence recorded. Analyse previews show the
|
category with merchant and confidence recorded. Analyse previews show the
|
||||||
low-confidence suggestion unselected for review. Transactions exposes a
|
low-confidence suggestion unselected for review. Transactions exposes a
|
||||||
Needs review filter for low-confidence or fallback rows. A model-proposed
|
Needs review filter for low-confidence or fallback rows, and a
|
||||||
|
classification filter over how each row was classified: manually, by AI,
|
||||||
|
by a merchant rule, by transfer matching, or not at all. A model-proposed
|
||||||
merchant name is dropped (the row keeps its validated category and tags)
|
merchant name is dropped (the row keeps its validated category and tags)
|
||||||
when it is identifier-shaped, longer than 100 characters, or contains
|
when it is identifier-shaped, longer than 100 characters, or contains
|
||||||
control or format code points such as bidirectional overrides and
|
control or format code points such as bidirectional overrides and
|
||||||
|
|||||||
@@ -423,7 +423,7 @@ Bank synchronization and recognized N26, ING, and Kontist CSV imports do **not**
|
|||||||
|
|
||||||
**Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import.
|
**Classify newly imported transactions with AI** under **Classification preferences** controls whether importing contacts the provider at all. It covers CSV imports and bank synchronization, is on by default, and is stored as `classify_on_import` in `config.toml`. With it off, no import makes a provider request: enabled merchant rules still classify, and everything else arrives unclassified and editable without a failure that would suggest the provider was unreachable. **AI classification → Analyse** still works on demand, so you can review a batch deliberately instead of on every import.
|
||||||
|
|
||||||
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both.
|
AI classification sends only identifier-redacted text: the transaction's own IDs, account identifiers and labels, payment references, labeled or IBAN-attached BICs, and configured private names are removed, while merchant and counterparty text remains available for recognition. Classification responses carry `high`, `medium`, or `low` confidence. Imports never auto-apply a low-confidence category — the row stays on the kind-appropriate unclassified category with the merchant link and confidence recorded — while **Analyse** previews show the low-confidence suggestion unselected for review, and **Transactions → Needs review** lists both. Transactions also filters by classification status — manual, AI, merchant rule, transfer match, or unclassified — matching the labels its Source column shows.
|
||||||
|
|
||||||
Classification choices retain their names, paths, hints, and aliases, but use short request-local references such as `c1`, `m1`, and `t1` instead of long database IDs. Merchant defaults and applicable classification history use the same references. Every eligible category, merchant, and tag remains available; responses are mapped back to canonical IDs and validated locally.
|
Classification choices retain their names, paths, hints, and aliases, but use short request-local references such as `c1`, `m1`, and `t1` instead of long database IDs. Merchant defaults and applicable classification history use the same references. Every eligible category, merchant, and tag remains available; responses are mapped back to canonical IDs and validated locally.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,26 @@ const EVENTS: Record<string, string> = {
|
|||||||
corporate_action: "Corporate action",
|
corporate_action: "Corporate action",
|
||||||
position_transfer: "Position transfer",
|
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
|
// 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.
|
// settles no money at all, so its zero amount is a fact and not a gap.
|
||||||
function positionOnly(investment?: Investment): boolean {
|
function positionOnly(investment?: Investment): boolean {
|
||||||
@@ -92,6 +112,7 @@ export function Transactions({
|
|||||||
}) {
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [needsReview, setNeedsReview] = useState(false);
|
const [needsReview, setNeedsReview] = useState(false);
|
||||||
|
const [status, setStatus] = useState("");
|
||||||
const [editing, setEditing] = useState<Transaction | null>(null);
|
const [editing, setEditing] = useState<Transaction | null>(null);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -122,6 +143,12 @@ export function Transactions({
|
|||||||
(e.kind === "income"
|
(e.kind === "income"
|
||||||
? "cat_income_unclassified"
|
? "cat_income_unclassified"
|
||||||
: "cat_expenses_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.tag_id || e.tag_ids.includes(filter.tag_id)) &&
|
||||||
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
|
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
|
||||||
(!query ||
|
(!query ||
|
||||||
@@ -134,7 +161,7 @@ export function Transactions({
|
|||||||
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
|
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
|
||||||
a.facts.id.localeCompare(b.facts.id),
|
a.facts.id.localeCompare(b.facts.id),
|
||||||
);
|
);
|
||||||
}, [data, filter, query, needsReview]);
|
}, [data, filter, query, needsReview, status]);
|
||||||
const currentPage = Math.min(
|
const currentPage = Math.min(
|
||||||
page,
|
page,
|
||||||
Math.max(0, Math.ceil(filtered.length / 40) - 1),
|
Math.max(0, Math.ceil(filtered.length / 40) - 1),
|
||||||
@@ -181,6 +208,22 @@ export function Transactions({
|
|||||||
/>
|
/>
|
||||||
Needs review
|
Needs review
|
||||||
</label>
|
</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">
|
<span className="muted small">
|
||||||
<SlidersHorizontal size={15} /> Click a transaction to edit
|
<SlidersHorizontal size={15} /> Click a transaction to edit
|
||||||
</span>
|
</span>
|
||||||
@@ -284,7 +327,8 @@ export function Transactions({
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span className="badge neutral">
|
<span className="badge neutral">
|
||||||
{e.classification.source}
|
{CLASSIFICATIONS[e.classification.source] ??
|
||||||
|
e.classification.source}
|
||||||
</span>
|
</span>
|
||||||
{e.classification.error && (
|
{e.classification.error && (
|
||||||
<small className="text-danger">
|
<small className="text-danger">
|
||||||
|
|||||||
@@ -744,6 +744,16 @@ main {
|
|||||||
.search input::placeholder {
|
.search input::placeholder {
|
||||||
color: #9aa6b3;
|
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 {
|
.table-scroll {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user