init
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ShieldCheck,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Save,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import type { State } from "./api";
|
||||
import { ErrorMessage, Field, Modal } from "./ui";
|
||||
import type { Mutate } from "./ui";
|
||||
export function Settings({ state, mutate }: { state: State; mutate: Mutate }) {
|
||||
const [model, setModel] = useState(state.settings.model);
|
||||
const [includeAmount, setIncludeAmount] = useState(
|
||||
state.settings.include_amount,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [rebuild, setRebuild] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2>Settings & privacy</h2>
|
||||
<p>Your data, your infrastructure, your choices.</p>
|
||||
</div>
|
||||
<span className="badge">
|
||||
<ShieldCheck size={14} />
|
||||
Self-hosted
|
||||
</span>
|
||||
</div>
|
||||
<ErrorMessage error={error} />
|
||||
<div className="dashboard-grid">
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>Classification preferences</h3>
|
||||
<p>Provider credentials are configured on the server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
className="form-body"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await mutate(
|
||||
"/api/settings",
|
||||
{ model: model.trim(), include_amount: includeAmount },
|
||||
"Classification preferences saved",
|
||||
);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Field label="Default AI model">
|
||||
<input
|
||||
required
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<label className="checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAmount}
|
||||
onChange={(e) => setIncludeAmount(e.target.checked)}
|
||||
/>
|
||||
Include transaction amount in AI requests
|
||||
</label>
|
||||
<p className="muted small">
|
||||
Disabled by default for privacy. Enabling this shares the amount
|
||||
with the configured AI provider to help classification.
|
||||
</p>
|
||||
<button className="button primary" disabled={busy}>
|
||||
<Save size={16} />
|
||||
{busy ? "Saving…" : "Save preferences"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>
|
||||
<ShieldCheck size={18} /> Privacy by design
|
||||
</h3>
|
||||
<p>Understand what leaves your server.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-body privacy-copy">
|
||||
<h4>Local source of truth</h4>
|
||||
<p>
|
||||
Your canonical journal is stored in plaintext files on your
|
||||
server. The analytical database is a rebuildable index, not the
|
||||
master copy.
|
||||
</p>
|
||||
<h4>Explicit external services</h4>
|
||||
<p>
|
||||
Bank authorization and sync use Enable Banking. AI classification
|
||||
sends allowlisted, sanitized fields to the configured provider.
|
||||
Known personal identifiers, counterparty names and bank references
|
||||
are stripped, but sanitization cannot guarantee that free-text
|
||||
descriptions contain no sensitive information.
|
||||
</p>
|
||||
<h4>Immutable originals</h4>
|
||||
<p>
|
||||
Imported bank facts are retained. Categories, tags and merchant
|
||||
associations are separate, editable enrichment with classification
|
||||
provenance.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<section className="panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>Service health</h3>
|
||||
<p>Errors stay visible so you can resolve the underlying issue.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="health-grid">
|
||||
<Health
|
||||
label="Banking provider"
|
||||
ok={state.status.banking_configured}
|
||||
detail={
|
||||
state.status.banking_configured ? "Configured" : "Not configured"
|
||||
}
|
||||
/>
|
||||
<Health
|
||||
label="AI provider"
|
||||
ok={state.status.ai_configured}
|
||||
detail={
|
||||
state.status.ai_configured ? "Configured" : "Not configured"
|
||||
}
|
||||
/>
|
||||
<Health
|
||||
label="Last bank sync"
|
||||
ok={!state.status.sync_error}
|
||||
detail={
|
||||
state.status.sync_error ||
|
||||
state.status.last_sync ||
|
||||
"No sync recorded"
|
||||
}
|
||||
/>
|
||||
<Health
|
||||
label="Analytics index"
|
||||
ok={!state.status.index_error}
|
||||
detail={state.status.index_error || "No index error reported"}
|
||||
/>
|
||||
</div>
|
||||
<div className="index-controls">
|
||||
<div>
|
||||
<h4>
|
||||
<Database size={17} /> Rebuild analytics index
|
||||
</h4>
|
||||
<p>
|
||||
Regenerate the query database from the canonical journal. This
|
||||
does not modify your bank facts or enrichment.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={busy}
|
||||
onClick={() => setRebuild(true)}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Rebuild index
|
||||
</button>
|
||||
</div>
|
||||
<details className="revision-details">
|
||||
<summary>Current journal revision</summary>
|
||||
<code>{state.revision}</code>
|
||||
<p className="muted small">
|
||||
Edits use this revision to avoid overwriting concurrent changes.
|
||||
Refresh when the journal is edited outside the app.
|
||||
</p>
|
||||
</details>
|
||||
</section>
|
||||
{rebuild && (
|
||||
<Modal
|
||||
title="Rebuild the analytics index?"
|
||||
close={() => {
|
||||
if (!busy) setRebuild(false);
|
||||
}}
|
||||
>
|
||||
<div className="form-body">
|
||||
<p>
|
||||
The derived index will be regenerated from your journal. Queries
|
||||
may be briefly unavailable while rebuilding.
|
||||
</p>
|
||||
<ErrorMessage error={error} />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button
|
||||
className="button secondary"
|
||||
disabled={busy}
|
||||
onClick={() => setRebuild(false)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={async () => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await mutate("/api/rebuild", {}, "Analytics index rebuilt");
|
||||
setRebuild(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{busy ? "Rebuilding…" : "Rebuild from journal"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
function Health({
|
||||
label,
|
||||
ok,
|
||||
detail,
|
||||
}: {
|
||||
label: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="health">
|
||||
<span className={ok ? "positive" : "text-danger"}>
|
||||
{ok ? <CheckCircle2 size={18} /> : <AlertCircle size={18} />}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{label}</strong>
|
||||
<p>{detail}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user