452 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|