import { useState } from "react"; import { Sparkles, ShieldCheck, Check, X, ArrowRight } from "lucide-react"; import type { Dataset, Enrichment, Preview, State } from "./api"; import { categoryPath, request } from "./api"; import { DateField, Empty, ErrorMessage, Field, Modal } 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 cancel = async () => { if (!preview) return; setBusy(true); setError(""); try { const response = await request<{ ok: boolean }>( "/api/reclassify/cancel", { id: preview.id }, ); if (!response.ok) throw new Error("The server did not confirm cancellation."); 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 allowlisted, sanitized fields are sent to the classification provider. Known identifiers and counterparty names are removed; free text can still contain sensitive information. Amount sharing is{" "} {state.settings.include_amount ? "enabled" : "disabled"} in Settings. AI requests may incur provider charges.

{!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 result = await request( "/api/reclassify/preview", { revision: state.revision, from, to, model: model.trim(), fields, }, ); if ( !result.id || !result.revision || !("changes" in result) || !("errors" in result) || !("new_merchants" in result) ) throw new Error( "The server returned an incompatible preview.", ); result.changes ??= []; result.errors ??= []; result.new_merchants ??= []; for (const change of result.changes) { change.before.tag_ids ??= []; change.after.tag_ids ??= []; } setPreview(result); setSelected(result.changes.map((c) => c.id)); } 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.

)} {busy && (

This can take a while for a large date range. Keep this page open.

)} {!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. Cancel it and generate a fresh preview before applying.
)}

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"}
); }