Files
finance-duck/web/src/Classification.tsx
T
2026-09-11 15:00:21 +02:00

452 lines
15 KiB
TypeScript

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<Preview | null>(null);
const [selected, setSelected] = useState<string[]>([]);
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 (
<>
<div className="section-heading">
<div>
<h2>AI classification</h2>
<p>A second look at your transactions. You stay in control.</p>
</div>
<span
className={`badge ${state.status.ai_configured ? "" : "neutral"}`}
>
<Sparkles size={14} />
{state.status.ai_configured ? "AI configured" : "AI not configured"}
</span>
</div>
<ErrorMessage error={error} />
<div className="privacy-banner">
<ShieldCheck size={24} />
<div>
<strong>Review first. Apply only what you choose.</strong>
<p>
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.
</p>
</div>
</div>
{!preview ? (
<section className="panel classification-setup">
<div className="panel-heading">
<div>
<h3>Prepare a preview</h3>
<p>
Nothing in your journal changes until you explicitly apply a
preview.
</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
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<Preview>(
"/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);
}
}}
>
<div className="two-columns">
<DateField
label="From"
value={from}
max={to || undefined}
onChange={setFrom}
/>
<DateField
label="To"
value={to}
min={from || undefined}
onChange={setTo}
/>
</div>
<Field
label="Model"
hint="An OpenAI-compatible model supported by your configured provider."
>
<input
required
value={model}
onChange={(e) => setModel(e.target.value)}
list="model-options"
/>
<datalist id="model-options">
<option value={state.settings.model} />
<option value="gpt-4.1-mini" />
<option value="gpt-4.1" />
<option value="gpt-4o-mini" />
</datalist>
</Field>
<fieldset className="tag-picker">
<legend>Fields to reclassify</legend>
{(["merchant", "category", "tags"] as const).map((field) => (
<label className="check-chip" key={field}>
<input
type="checkbox"
checked={fields[field]}
onChange={(e) =>
setFields({ ...fields, [field]: e.target.checked })
}
/>
{field === "tags"
? "Tags"
: field[0].toUpperCase() + field.slice(1)}
</label>
))}
</fieldset>
{!state.status.ai_configured && (
<p className="muted">
Configure your AI provider API key on the server to generate
previews. Existing manual classifications remain usable without
AI.
</p>
)}
<button
className="button primary"
disabled={
busy ||
!state.status.ai_configured ||
!Object.values(fields).some(Boolean) ||
!state.data.transactions.length
}
>
<Sparkles size={17} />
{busy ? "Classifying transactions…" : "Generate preview"}
</button>
{busy && (
<p role="status" className="muted">
This can take a while for a large date range. Keep this page
open.
</p>
)}
{!state.data.transactions.length && (
<p className="muted">
Import transactions from Accounts before generating a preview.
</p>
)}
</form>
</section>
) : (
<>
<div className="preview-summary">
<span>
<strong>{preview.analysed}</strong> analysed
</span>
<span>
<strong>{preview.changes.length}</strong> proposed changes
</span>
<span>
<strong>{preview.unchanged}</strong> unchanged
</span>
<span>
<strong>{preview.errors.length}</strong> errors
</span>
</div>
{preview.revision !== state.revision && (
<div className="alert error">
Your journal changed since this preview. Cancel it and generate a
fresh preview before applying.
</div>
)}
<section className="panel">
<div className="panel-heading">
<div>
<h3>Review changes</h3>
<p>
{selected.length} of {preview.changes.length} selected
</p>
</div>
<div className="row-actions">
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected(preview.changes.map((c) => c.id))}
>
Select all
</button>
<button
className="button subtle"
disabled={busy}
onClick={() => setSelected([])}
>
Clear selection
</button>
</div>
</div>
{preview.changes.length ? (
<div className="preview-list">
{preview.changes.map((change) => (
<label
className={`preview-row ${selected.includes(change.id) ? "selected" : ""}`}
key={change.id}
>
<input
type="checkbox"
checked={selected.includes(change.id)}
disabled={busy}
onChange={(e) =>
setSelected(
e.target.checked
? [...selected, change.id]
: selected.filter((id) => id !== change.id),
)
}
/>
<div>
<strong>{change.description || change.id}</strong>
<small className="muted">{change.id}</small>
<div className="diff">
<EnrichmentView
data={state.data}
value={change.before}
label="Before"
/>
<ArrowRight size={18} />
<EnrichmentView
data={previewData}
value={change.after}
label="Proposed"
/>
</div>
</div>
</label>
))}
</div>
) : (
<Empty title="No changes proposed">
Your classifications already match the result for this
selection.
</Empty>
)}
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={cancel}
>
<X size={16} />
{busy ? "Working…" : "Cancel preview"}
</button>
<button
className="button primary"
disabled={
busy ||
!selected.length ||
preview.revision !== state.revision
}
onClick={() => setConfirm(true)}
>
<Check size={16} />
Apply {selected.length} selected
</button>
</div>
</section>
{preview.errors.length > 0 && (
<section className="panel">
<div className="panel-heading">
<h3>Transactions that could not be classified</h3>
</div>
<div className="form-body">
{preview.errors.map((item, i) => (
<div className="alert error" key={`${item.id}-${i}`}>
<div>
<strong>{item.id}</strong>
<p>{item.error}</p>
</div>
</div>
))}
</div>
</section>
)}
</>
)}
{confirm && preview && (
<Modal
title="Apply selected classifications?"
close={() => {
if (!busy) setConfirm(false);
}}
>
<div className="form-body">
<p>
This will replace the selected enrichment fields on{" "}
<strong>{selected.length} transactions</strong> in one journal
commit. Unselected proposals will not be applied. Original bank
facts remain unchanged.
</p>
<ErrorMessage error={error} />
</div>
<div className="form-actions">
<button
className="button secondary"
disabled={busy}
onClick={() => setConfirm(false)}
>
Keep reviewing
</button>
<button
className="button primary"
disabled={busy || preview.revision !== state.revision}
onClick={async () => {
setBusy(true);
setError("");
try {
const result = await request<State>("/api/reclassify/apply", {
id: preview.id,
revision: preview.revision,
transaction_ids: selected,
});
acceptState(
result,
`Applied ${selected.length} classifications`,
);
setPreview(null);
setSelected([]);
setConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
{busy ? "Applying…" : "Confirm and apply"}
</button>
</div>
</Modal>
)}
</>
);
}
function EnrichmentView({
data,
value,
label,
}: {
data: Dataset;
value: Enrichment;
label: string;
}) {
return (
<div className="diff-value">
<span className="eyebrow">{label}</span>
<dl>
<div>
<dt>Merchant</dt>
<dd>
{value.merchant_id
? data.merchants.find((m) => m.id === value.merchant_id)?.name ||
`New merchant (${value.merchant_id})`
: "None"}
</dd>
</div>
<div>
<dt>Category</dt>
<dd>{categoryPath(data, value.category_id)}</dd>
</div>
<div>
<dt>Tags</dt>
<dd>
{value.tag_ids.length
? value.tag_ids
.map((id) => data.tags.find((t) => t.id === id)?.name || id)
.join(", ")
: "None"}
</dd>
</div>
</dl>
</div>
);
}