import { useEffect, useRef, useState } from "react"; import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react"; import type { Dataset, Enrichment, Preview, PreviewProgress, State, } from "./api"; import { categoryPath, money, request } from "./api"; import { DateField, Empty, ErrorMessage, Field, Modal, ModelOptions, } from "./ui"; export function Classification({ state, acceptState, }: { state: State; acceptState: (state: State, message?: string) => void; }) { 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 }); 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); 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([]); } 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; 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 { const start = await request( "/api/reclassify/preview", { revision: state.revision, 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

{preview.changes.length ? (
{preview.changes.map((change) => ( ))}
) : ( 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. 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"}
); } // 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`; }