import { useEffect, useRef, useState } from "react"; import { Sparkles, ShieldCheck, Check, X, ArrowRight, RotateCcw, } from "lucide-react"; import type { Dataset, Enrichment, Preview, PreviewProgress, State, } from "./api"; import { categoryPath, money, request } from "./api"; import { CategoryCombobox, Combobox, createTag, DateField, Empty, ErrorMessage, Field, Modal, ModelOptions, } from "./ui"; import type { Mutate } from "./ui"; export function Classification({ state, acceptState, mutate, }: { state: State; acceptState: (state: State, message?: string) => void; mutate: Mutate; }) { const dates = state.data.transactions.map((t) => t.facts.booking_date).sort(); const [from, setFrom] = useState(dates[0] || ""); const [to, setTo] = useState(dates[dates.length - 1] || ""); const [model, setModel] = useState(state.settings.model); const [fields, setFields] = useState({ merchant: true, category: true, tags: true, }); const [preview, setPreview] = useState(null); const [selected, setSelected] = useState([]); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [confirm, setConfirm] = useState(false); const [running, setRunning] = useState(null); const runStart = useRef({ time: 0, analysed: 0 }); // Reviewer corrections to proposals, keyed by transaction id. A correction // that matches the proposal again is dropped, so presence means "edited". const [edits, setEdits] = useState>({}); const finalize = (result: Preview) => { result.changes ??= []; result.errors ??= []; result.new_merchants ??= []; for (const change of result.changes) { change.before.tag_ids ??= []; change.after.tag_ids ??= []; } const confidenceRank: Record = { low: 0, medium: 1, high: 2, }; result.changes.sort( (a, b) => (confidenceRank[a.after.classification.confidence || "low"] ?? 0) - (confidenceRank[b.after.classification.confidence || "low"] ?? 0), ); setPreview(result); setEdits({}); setSelected( result.changes .filter((change) => change.after.classification.confidence !== "low") .map((change) => change.id), ); }; // A run keeps going on the server while this page is closed; re-attach to // it on mount instead of presenting a fresh, contradictory setup form. useEffect(() => { let stale = false; (async () => { try { const p = await request("/api/reclassify/progress", { id: "", }); if (stale) return; if (!p.done) { runStart.current = { time: Date.now(), analysed: p.analysed }; setRunning(p); } else if ( !p.error && p.preview && p.preview.revision === state.revision ) { finalize(p.preview); } } catch { // No run to re-attach to. } })(); return () => { stale = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { if (!running || running.done) return; const timer = setTimeout(async () => { try { const p = await request("/api/reclassify/progress", { id: running.id, }); p.errors ??= []; if (!p.done) { setRunning(p); return; } setRunning(null); if (p.error) setError(p.error); else if (p.preview) finalize(p.preview); } catch (err) { setRunning(null); setError(err instanceof Error ? err.message : String(err)); } }, 1200); return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps }, [running]); const cancel = async (id: string) => { setBusy(true); setError(""); try { const response = await request<{ ok: boolean }>( "/api/reclassify/cancel", { id }, ); if (!response.ok) throw new Error("The server did not confirm cancellation."); setRunning(null); setPreview(null); setSelected([]); setEdits({}); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }; const previewData = preview ? { ...state.data, merchants: [...state.data.merchants, ...preview.new_merchants], } : state.data; // The value a change will be applied with: the reviewer's correction when // one exists, otherwise the model's proposal. const effective = (change: Preview["changes"][number]): CorrectionValue => edits[change.id] ?? { category_id: change.after.category_id || "", tag_ids: change.after.tag_ids, }; const correct = ( change: Preview["changes"][number], value: CorrectionValue, ) => { const proposal = change.after; const same = value.category_id === (proposal.category_id || "") && value.tag_ids.length === proposal.tag_ids.length && value.tag_ids.every((id) => proposal.tag_ids.includes(id)); setEdits((prev) => { const next = { ...prev }; if (same) delete next[change.id]; else next[change.id] = value; return next; }); // Correcting a row is a decision to apply it. if (!same) setSelected((ids) => ids.includes(change.id) ? ids : [...ids, change.id], ); }; return ( <>

AI classification

A second look at your transactions. You stay in control.

{state.status.ai_configured ? "AI configured" : "AI not configured"}
Review first. Apply only what you choose.

Only identifier-shaped values are redacted before sending. Merchant and counterparty text, amount, date and currency are sent so the provider can classify the row. Your own account IBAN, account IDs, transaction IDs, references and configured private names are never sent. AI requests may incur provider charges.

{running ? (

Classifying transactions…

{running.analysed} of {running.total} analysed ·{" "} {running.changes} proposed changes · {running.unchanged}{" "} unchanged · {running.errors.length} errors

Provider requests are spaced several seconds apart to respect rate limits {remainingEstimate(running, runStart.current)}. You can leave this page; the preview keeps building and will be here when you return.

{running.errors.length > 0 && (
{running.errors.length} transaction {running.errors.length === 1 ? "" : "s"} failed so far

{running.errors[running.errors.length - 1].error}

)}
) : !preview ? (

Prepare a preview

Nothing in your journal changes until you explicitly apply a preview.

{ e.preventDefault(); // The month-name picker is not a native date control, so the // range is validated here instead of by form constraints. if (!from || !to) { setError("Choose a start and an end date for the range."); return; } setBusy(true); setError(""); try { // Refresh registry labels for the review, but let the server // take its own snapshot so another write cannot race analysis. acceptState(await request("/api/state")); const start = await request( "/api/reclassify/preview", { from, to, model: model.trim(), fields, }, ); if (!start.id) throw new Error( "The server returned an incompatible preview run.", ); start.errors ??= []; runStart.current = { time: Date.now(), analysed: 0 }; setRunning(start); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } }} >
setModel(e.target.value)} list="model-options" />
Fields to reclassify {(["merchant", "category", "tags"] as const).map((field) => ( ))}
{!state.status.ai_configured && (

Configure your AI provider API key on the server to generate previews. Existing manual classifications remain usable without AI.

)} {!state.data.transactions.length && (

Import transactions from Accounts before generating a preview.

)}
) : ( <>
{preview.analysed} analysed {preview.changes.length} proposed changes {preview.unchanged} unchanged {preview.errors.length} errors
{preview.revision !== state.revision && (
Your journal changed since this preview. Selected changes still apply as long as their transactions were not edited in the meantime.
)}

Review changes

{selected.length} of {preview.changes.length} selected — correct any proposed category or tags in place; corrections are recorded as manual classifications.

{preview.changes.length ? (
{preview.changes.map((change) => (
setSelected( e.target.checked ? [...selected, change.id] : selected.filter((id) => id !== change.id), ) } />
{change.description || change.counterparty || change.id} {money(change.amount, change.currency)} {change.id} Confidence:{" "} {change.after.classification.confidence || "unknown"}
correct(change, value)} />
))}
) : ( Your classifications already match the result for this selection. )}
{preview.errors.length > 0 && (

Transactions that could not be classified

{preview.errors.map((item, i) => (
{item.id}

{item.error}

))}
)} )} {confirm && preview && ( { if (!busy) setConfirm(false); }} >

This will replace the selected enrichment fields on{" "} {selected.length} transactions in one journal commit. {selected.filter((id) => id in edits).length > 0 && ( <> {" "} {selected.filter((id) => id in edits).length} {" "} of them carry your corrections and will be recorded as manual classifications. )}{" "} Unselected proposals will not be applied. Original bank facts remain unchanged.

)} ); } function EnrichmentView({ data, value, label, }: { data: Dataset; value: Enrichment; label: string; }) { return (
{label}
Merchant
{value.merchant_id ? data.merchants.find((m) => m.id === value.merchant_id)?.name || `New merchant (${value.merchant_id})` : "None"}
Category
{categoryPath(data, value.category_id)}
Tags
{value.tag_ids.length ? value.tag_ids .map((id) => data.tags.find((t) => t.id === id)?.name || id) .join(", ") : "None"}
); } // CorrectionValue is the pair of fields a reviewer may correct on a proposal // before applying it. Merchants are minted by the model and stay read-only. interface CorrectionValue { category_id: string; tag_ids: string[]; } // CorrectionEditor is the "Proposed" side of a review row, editable in place. // Category and tags are free-text inputs that autocomplete against the // existing taxonomy and can create a missing entry in place; the category // list is limited to leaves of the change's kind because that is what // validation will accept. Creating mid-review bumps the journal revision, // which the apply path tolerates as long as the transactions themselves are // untouched. function CorrectionEditor({ data, change, value, edited, disabled, mutate, onChange, }: { data: Dataset; change: Preview["changes"][number]; value: CorrectionValue; edited: boolean; disabled: boolean; mutate: Mutate; onChange: (value: CorrectionValue) => void; }) { // Async creates resolve against the freshest correction, not the snapshot // captured when the create row was clicked: a chip removed during the // server round trip must survive the create landing. const latest = useRef(value); latest.current = value; const addable = data.tags .filter((t) => !value.tag_ids.includes(t.id)) .map((t) => ({ value: t.id, label: t.name })); return (
Proposed{edited ? " · edited" : ""} {edited && ( )}
Merchant
{change.after.merchant_id ? data.merchants.find((m) => m.id === change.after.merchant_id) ?.name || `New merchant (${change.after.merchant_id})` : "None"}
Category
onChange({ ...latest.current, category_id }) } />
Tags
{value.tag_ids.map((id) => ( ))} onChange({ ...value, tag_ids: [...value.tag_ids, id] }) } placeholder={data.tags.length ? "Add tag" : "Add or create tag"} emptyText="No matching tag. Type a name to create it." create={(text) => data.tags.some( (t) => t.name.toLowerCase() === text.toLowerCase(), ) ? [] : [ { key: "tag", label: `Create tag "${text}"`, run: async () => { const id = await createTag(mutate, data, text); onChange({ ...latest.current, tag_ids: [...latest.current.tag_ids, id], }); }, }, ] } />
); } // remainingEstimate projects the finish time from the pace observed since // this page attached to the run; the server paces provider requests, so the // first sample is meaningless and re-attaching mid-run must not count work // done before it. function remainingEstimate( p: PreviewProgress, start: { time: number; analysed: number }, ): string { const sampled = p.analysed - start.analysed; const remaining = p.total - p.analysed; if (remaining <= 0 || sampled < 2 || !start.time) return ""; const seconds = Math.round( ((Date.now() - start.time) / sampled / 1000) * remaining, ); if (seconds < 90) return ` — roughly ${seconds} seconds remaining`; return ` — roughly ${Math.round(seconds / 60)} minutes remaining`; }