This commit is contained in:
Lars Nolden
2026-09-10 12:30:42 +02:00
commit 9843fe0c50
79 changed files with 16318 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
package frontend
import (
"embed"
"io/fs"
)
//go:embed all:dist
var files embed.FS
func Assets() (fs.FS, error) { return fs.Sub(files, "dist") }
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#111e2d" />
<meta
name="description"
content="Your private, self-hosted personal finance workspace."
/>
<title>Finance Duck · Your financial picture</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1910
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "finance-duck-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "tsc -b && vite build",
"format": "prettier --write src package.json tsconfig.json vite.config.ts index.html"
},
"dependencies": {
"lucide-react": "^0.468.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^4.7.0",
"prettier": "3.6.2",
"typescript": "^5.9.2",
"vite": "^6.3.5"
}
}
+690
View File
@@ -0,0 +1,690 @@
import { useRef, useState } from "react";
import {
Plus,
Upload,
Link2,
Wallet,
Pencil,
Trash2,
RefreshCw,
} from "lucide-react";
import type { Account, State } from "./api";
import { money, request } from "./api";
import { Empty, ErrorMessage, Field, FormActions, Modal } from "./ui";
import type { Mutate } from "./ui";
interface Balance {
amount: string;
currency: string;
type: string;
}
export function Accounts({
state,
mutate,
acceptState,
}: {
state: State;
mutate: Mutate;
acceptState: (state: State, message?: string) => void;
}) {
const [editing, setEditing] = useState<Account | null>(null);
const [deleting, setDeleting] = useState<Account | null>(null);
const [error, setError] = useState("");
const [syncing, setSyncing] = useState(false);
return (
<>
<div className="section-heading">
<div>
<h2>Accounts & connections</h2>
<p>Bring your financial picture together, one account at a time.</p>
</div>
<button
className="button primary"
onClick={() =>
setEditing({
id: "",
display_name: "",
institution: "N26",
currency: "EUR",
active: true,
})
}
>
<Plus size={17} />
Add account
</button>
</div>
<ErrorMessage error={error} />
<div className="account-grid">
{state.data.accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
state={state}
edit={() => setEditing(account)}
remove={() => setDeleting(account)}
onError={setError}
/>
))}
</div>
{!state.data.accounts.length && (
<section className="panel">
<Empty title="Make room for your first account">
Add an account to import CSV statements, or connect your bank below.
</Empty>
</section>
)}
<div className="dashboard-grid">
<ImportForm
state={state}
acceptState={acceptState}
onError={setError}
/>
<ConnectForm state={state} onError={setError} />
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Bank sessions</h3>
<p>
Reconnect when a session expires. Imported journal history is
retained.
</p>
</div>
<button
className="button secondary"
disabled={
syncing ||
!state.status.banking_configured ||
!state.sessions.length
}
onClick={async () => {
setSyncing(true);
setError("");
try {
await mutate("/api/sync", {}, "Bank sync completed");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSyncing(false);
}
}}
>
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing…" : "Sync now"}
</button>
</div>
{state.sessions.length ? (
<div className="session-list">
{state.sessions.map((session) => (
<div className="session" key={session.session_id}>
<div>
<strong>Bank connection</strong>
<small>
Valid until {session.valid_until || "not supplied"}
</small>
<code>{session.session_id}</code>
</div>
<div>
{session.accounts.map((account) => (
<span className="badge neutral" key={account.id}>
{account.display_name}
</span>
))}
</div>
</div>
))}
</div>
) : (
<div className="compact-empty">
No bank sessions. CSV imports work without a bank connection.
</div>
)}
</section>
{editing && (
<AccountEditor
account={editing}
mutate={mutate}
close={() => setEditing(null)}
/>
)}
{deleting && (
<DeleteAccount
account={deleting}
mutate={mutate}
close={() => setDeleting(null)}
/>
)}
</>
);
}
async function authorize(institution: string, country: string) {
const response = await request<{ url: string }>("/api/banking/authorize", {
institution,
country,
});
const url = new URL(response.url);
if (url.protocol !== "https:")
throw new Error("Bank authorization returned an unsafe redirect URL.");
window.location.assign(url.href);
}
function AccountCard({
account,
state,
edit,
remove,
onError,
}: {
account: Account;
state: State;
edit: () => void;
remove: () => void;
onError: (error: string) => void;
}) {
const [balances, setBalances] = useState<Balance[] | null>(null);
const [busy, setBusy] = useState(false);
const [connecting, setConnecting] = useState(false);
const connection = state.connections.find((c) => c.account_id === account.id);
const institution = connection?.institution || account.institution;
const needsReconnect = connection?.status === "reconnect_required";
return (
<section className="panel account-card">
<div className="account-card-heading">
<span className="account-icon">
<Wallet size={23} />
</span>
<span className={`badge ${account.active ? "" : "neutral"}`}>
{account.active ? "Active" : "Inactive"}
</span>
</div>
<h3>{account.display_name}</h3>
<p>
{account.institution} · {account.currency}
</p>
{account.iban && <small className="account-iban">{account.iban}</small>}
<div className="connection-status">
<span
className={`badge ${needsReconnect || connection?.status === "error" ? "connection-warning" : "neutral"}`}
>
{needsReconnect
? `${institution} needs reconnection`
: connection?.status === "connected"
? "Bank connected"
: connection?.status === "error"
? "Connection error"
: connection?.status === "local"
? "Local account"
: "Connection status unavailable"}
</span>
{connection?.valid_until && (
<small>Authorization expires {connection.valid_until}</small>
)}
{connection?.error && (
<small className="text-danger">{connection.error}</small>
)}
{connection && connection.status !== "local" && (
<button
className="button secondary"
disabled={connecting || !state.status.banking_configured}
onClick={async () => {
setConnecting(true);
onError("");
try {
await authorize(institution, connection.country || "DE");
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setConnecting(false);
}
}}
>
<Link2 size={14} />
{connecting ? "Opening bank…" : `Reconnect ${institution}`}
</button>
)}
</div>
<div className="balance-area">
{balances ? (
balances.length ? (
balances.map((balance, i) => (
<div key={i}>
<small>{balance.type}</small>
<strong>{money(balance.amount, balance.currency)}</strong>
</div>
))
) : (
<span className="muted small">
No balances returned for this account.
</span>
)
) : (
<span className="muted small">
Live balance has not been requested.
</span>
)}
</div>
<div className="account-actions">
<button
className="button subtle"
disabled={
busy ||
!account.external_account_id ||
!state.status.banking_configured
}
title={
!account.external_account_id
? "Connect a provider account to load live balances"
: undefined
}
onClick={async () => {
setBusy(true);
onError("");
try {
const result = await request<Balance[]>(
`/api/balances?account_id=${encodeURIComponent(account.id)}`,
);
if (!Array.isArray(result))
throw new Error("The server returned invalid balances.");
setBalances(result);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<RefreshCw size={14} />
{busy ? "Loading…" : "Balance"}
</button>
<div className="row-actions">
<button
className="icon-button"
aria-label={`Edit ${account.display_name}`}
onClick={edit}
>
<Pencil size={16} />
</button>
<button
className="icon-button danger"
aria-label={`Delete ${account.display_name}`}
onClick={remove}
>
<Trash2 size={16} />
</button>
</div>
</div>
</section>
);
}
function ImportForm({
state,
acceptState,
onError,
}: {
state: State;
acceptState: (state: State, message?: string) => void;
onError: (error: string) => void;
}) {
const [account, setAccount] = useState("");
const [busy, setBusy] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Upload size={18} /> Import a statement
</h3>
<p>N26 CSV · German and English exports supported</p>
</div>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
const file = fileRef.current?.files?.[0];
if (!file) return;
setBusy(true);
onError("");
const form = new FormData();
form.set("account_id", account);
form.set("revision", state.revision);
form.set("file", file);
try {
const response = await request<{ imported: number; state: State }>(
"/api/import",
form,
);
acceptState(
response.state,
`Imported ${response.imported} new transactions. Existing transactions were not duplicated.`,
);
if (fileRef.current) fileRef.current.value = "";
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<Field label="Import into account">
<select
required
value={account}
onChange={(e) => setAccount(e.target.value)}
>
<option value="">Choose an account</option>
{state.data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name} ({a.currency})
</option>
))}
</select>
</Field>
<Field label="CSV statement">
<input ref={fileRef} required type="file" accept=".csv,text/csv" />
</Field>
<p className="muted small">
Original descriptions and amounts are preserved. Reimporting the same
statement safely skips transactions already in your journal.
</p>
<button
className="button primary"
disabled={busy || !state.data.accounts.length}
>
<Upload size={16} />
{busy ? "Importing statement…" : "Import CSV"}
</button>
</form>
</section>
);
}
function ConnectForm({
state,
onError,
}: {
state: State;
onError: (error: string) => void;
}) {
const [institution, setInstitution] = useState("");
const [country, setCountry] = useState("DE");
const [busy, setBusy] = useState(false);
const [copied, setCopied] = useState(false);
const callback =
state.callback_url || `${window.location.origin}/api/banking/callback`;
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>
<Link2 size={18} /> Connect your bank
</h3>
<p>Secure authorization through Enable Banking</p>
</div>
<span
className={`badge ${state.status.banking_configured ? "" : "neutral"}`}
>
{state.status.banking_configured ? "Configured" : "Not configured"}
</span>
</div>
<div className="callback-details">
<Field
label={
state.callback_url
? "Configured callback URL"
: "Suggested callback URL (not configured)"
}
>
<input readOnly value={callback} onFocus={(e) => e.target.select()} />
</Field>
<button
className="button secondary"
onClick={async () => {
try {
if (!navigator.clipboard)
throw new Error(
"Clipboard unavailable. Select and copy the callback URL above.",
);
await navigator.clipboard.writeText(callback);
setCopied(true);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
}
}}
>
{copied ? "Copied" : "Copy callback URL"}
</button>
<p>
Set <code>ENABLEBANKING_REDIRECT_URL</code> on your server and
register this exact URL with Enable Banking. It must match exactly,
including scheme, hostname, port and path.
</p>
</div>
<form
className="form-body"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
onError("");
try {
await authorize(institution.trim(), country);
} catch (err) {
onError(err instanceof Error ? err.message : String(err));
setBusy(false);
}
}}
>
<Field
label="Institution"
hint="Use the institution name recognized by Enable Banking, such as N26."
>
<input
required
value={institution}
onChange={(e) => setInstitution(e.target.value)}
placeholder="N26"
/>
</Field>
<Field label="Country" hint="Two-letter country code">
<input
required
pattern="[A-Z]{2}"
maxLength={2}
value={country}
onChange={(e) => setCountry(e.target.value.toUpperCase())}
/>
</Field>
{!state.status.banking_configured && (
<p className="muted small">
Set the Enable Banking application ID, signing key and callback URL
on your server first. Credentials never enter this browser form.
</p>
)}
<button
className="button primary"
disabled={busy || !state.status.banking_configured}
>
<Link2 size={16} />
{busy ? "Opening bank…" : "Authorize bank"}
</button>
</form>
</section>
);
}
function AccountEditor({
account,
mutate,
close,
}: {
account: Account;
mutate: Mutate;
close: () => void;
}) {
const [value, setValue] = useState(account);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
return (
<Modal title={account.id ? "Edit account" : "Add an account"} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/accounts",
{
account: {
...value,
display_name: value.display_name.trim(),
institution: value.institution.trim(),
iban: value.iban?.replaceAll(" ", ""),
},
},
"Account saved",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Display name">
<input
autoFocus
required
value={value.display_name}
onChange={(e) =>
setValue({ ...value, display_name: e.target.value })
}
placeholder="Everyday account"
/>
</Field>
<div className="two-columns">
<Field label="Institution">
<input
required
value={value.institution}
onChange={(e) =>
setValue({ ...value, institution: e.target.value })
}
/>
</Field>
<Field label="Currency">
<input
required
pattern="[A-Z]{3}"
maxLength={3}
value={value.currency}
onChange={(e) =>
setValue({ ...value, currency: e.target.value.toUpperCase() })
}
/>
</Field>
</div>
<Field
label="IBAN (optional)"
hint="Used to recognize transfers between your own accounts."
>
<input
value={value.iban || ""}
onChange={(e) => setValue({ ...value, iban: e.target.value })}
/>
</Field>
<Field
label="External account ID (optional)"
hint="The provider account identifier used for connected-bank sync."
>
<input
value={value.external_account_id || ""}
onChange={(e) =>
setValue({ ...value, external_account_id: e.target.value })
}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={value.active}
onChange={(e) => setValue({ ...value, active: e.target.checked })}
/>
Account is active
</label>
</div>
<FormActions
busy={busy}
close={close}
label={account.id ? "Save changes" : "Create account"}
/>
</form>
</Modal>
);
}
function DeleteAccount({
account,
mutate,
close,
}: {
account: Account;
mutate: Mutate;
close: () => void;
}) {
const [confirm, setConfirm] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
return (
<Modal title={`Delete ${account.display_name}?`} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/manage",
{ entity: "account", action: "delete", id: account.id },
"Account deleted",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
An account with transactions cannot be deleted. Deactivate it
instead to preserve its history.
</p>
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
Permanently delete this empty account.
</label>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={close}
disabled={busy}
>
Cancel
</button>
<button className="button destructive" disabled={!confirm || busy}>
{busy ? "Deleting…" : "Delete account"}
</button>
</div>
</form>
</Modal>
);
}
+451
View File
@@ -0,0 +1,451 @@
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 { 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();
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">
<Field label="From">
<input
required
type="date"
max={to || undefined}
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</Field>
<Field label="To">
<input
required
type="date"
min={from || undefined}
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</Field>
</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>
);
}
+480
View File
@@ -0,0 +1,480 @@
import { useEffect, useState } from "react";
import {
ArrowDownLeft,
ArrowUpRight,
Wallet,
ArrowRight,
TrendingUp,
} from "lucide-react";
import type { Dashboard, Dataset, Filter, Group } from "./api";
import { money, request } from "./api";
import { Empty, ErrorMessage, Filters } from "./ui";
export function Overview({
data,
revision,
filter,
setFilter,
navigate,
}: {
data: Dataset;
revision: string;
filter: Filter;
setFilter: (f: Filter) => void;
navigate: (page: string) => void;
}) {
const [dashboard, setDashboard] = useState<Dashboard | null>(null);
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [retry, setRetry] = useState(0);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError("");
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter))
if (value) params.set(key, value);
request<Dashboard>(`/api/dashboard?${params}`, undefined, controller.signal)
.then((value) => {
for (const key of [
"totals",
"previous",
"monthly",
"categories",
"tags",
"merchants",
"accounts",
"recurring",
] as const) {
if (!(key in value))
throw new Error(`Dashboard response is missing ${key}.`);
if (value[key] === null) Object.assign(value, { [key]: [] });
}
setDashboard(value);
})
.catch((err) => {
if (!controller.signal.aborted) {
setError(err instanceof Error ? err.message : String(err));
setDashboard(null);
}
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [revision, filter, retry]);
return (
<>
<div className="section-heading">
<div>
<h2>Your financial picture</h2>
<p>A little clarity for the decisions ahead.</p>
</div>
<button
className="button secondary"
onClick={() => navigate("transactions")}
>
View transactions <ArrowRight size={16} />
</button>
</div>
<Filters data={data} value={filter} onChange={setFilter} />
<ErrorMessage error={error} />
{error && (
<button
className="button secondary"
onClick={() => setRetry(retry + 1)}
>
Retry analytics
</button>
)}
{loading ? (
<div className="loading-block" role="status">
<span className="spinner" />
Reading your financial picture
</div>
) : (
dashboard && (
<>
{!data.transactions.length && (
<section className="welcome panel">
<div className="welcome-copy">
<span className="eyebrow">A fresh start</span>
<h3>
All your finances.
<br />A space of your own.
</h3>
<p>
Your private journal is ready. Add an account and import
your first statement to see the bigger picture.
</p>
<button
className="button primary"
onClick={() => navigate("accounts")}
>
Set up your accounts <ArrowRight size={16} />
</button>
</div>
<div className="welcome-art" aria-hidden="true">
<Wallet size={70} />
<span>Your data. Your server.</span>
</div>
</section>
)}
{dashboard.totals.map((total) => {
const previous = dashboard.previous.find(
(t) => t.currency === total.currency,
);
return (
<div className="stat-grid" key={total.currency}>
{(
[
{
key: "income",
label: "Money in",
Icon: ArrowDownLeft,
className: "positive",
},
{
key: "expenses",
label: "Money out",
Icon: ArrowUpRight,
className: "",
},
{
key: "net",
label: "Net cash flow",
Icon: Wallet,
className: total.net.startsWith("-") ? "" : "positive",
},
] as const
).map(({ key, label, Icon, className }) => (
<section className="panel stat" key={key}>
<div className="stat-top">
<span>{label}</span>
<span className={`stat-icon ${key}`}>
<Icon size={19} />
</span>
</div>
<strong className={`stat-value ${className}`}>
{money(total[key], total.currency)}
</strong>
<small>
{previous
? `Previous period ${money(previous[key], previous.currency)}`
: "No previous-period activity"}
</small>
</section>
))}
</div>
);
})}
{data.transactions.length > 0 && !dashboard.totals.length && (
<section className="panel">
<Empty title="No activity in this view">
Adjust your filters to include more transactions.
</Empty>
</section>
)}
<div className="dashboard-grid">
<section className="panel chart-panel">
<div className="panel-heading">
<div>
<h3>Monthly cash flow</h3>
<p>Net movement over time · transfers excluded</p>
</div>
<TrendingUp size={20} />
</div>
<MonthlyChart groups={dashboard.monthly} />
</section>
<CategoryTree
data={data}
groups={dashboard.categories}
onSelect={(id) => {
setFilter({ ...filter, category_id: id });
navigate("transactions");
}}
/>
</div>
<div className="dashboard-grid thirds">
<GroupPanel
title="Merchants"
subtitle="Where your money goes"
groups={dashboard.merchants}
onSelect={(id) => {
setFilter({ ...filter, merchant_id: id });
navigate("transactions");
}}
/>
<GroupPanel
title="Accounts"
subtitle="Movement by account"
groups={dashboard.accounts}
onSelect={(id) => {
setFilter({ ...filter, account_id: id });
navigate("transactions");
}}
/>
<GroupPanel
title="Tags"
subtitle="Your custom perspective"
groups={dashboard.tags}
onSelect={(id) => {
setFilter({ ...filter, tag_id: id });
navigate("transactions");
}}
/>
</div>
<section className="panel">
<div className="panel-heading">
<div>
<h3>Recurring patterns</h3>
<p>Repeated payments detected in the selected period</p>
</div>
<span className="badge neutral">Observed, not forecast</span>
</div>
{dashboard.recurring.length ? (
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Merchant / payment</th>
<th>Frequency</th>
<th>Occurrences</th>
<th className="numeric">Observed total</th>
</tr>
</thead>
<tbody>
{dashboard.recurring.map((g, i) => (
<tr key={`${g.id}-${g.currency}-${i}`}>
<td>{g.name}</td>
<td>{g.period}</td>
<td>{g.count}</td>
<td className="numeric money">
{money(g.amount, g.currency)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="compact-empty">
No recurring patterns detected in this period.
</div>
)}
</section>
</>
)
)}
</>
);
}
function CategoryTree({
data,
groups,
onSelect,
}: {
data: Dataset;
groups: Group[];
onSelect: (id: string) => void;
}) {
const [expanded, setExpanded] = useState<string[]>(
data.categories.filter((c) => !c.parent_id).map((c) => c.id),
);
const [showAll, setShowAll] = useState(false);
const available = new Set(groups.map((g) => g.id));
const roots = data.categories.filter(
(c) => !c.parent_id && available.has(c.id),
);
const node = (id: string, depth: number): React.ReactNode => {
const category = data.categories.find((c) => c.id === id);
const children = data.categories.filter(
(c) => c.parent_id === id && available.has(c.id),
);
const rows = groups.filter((g) => g.id === id);
return (
<div key={id} className="category-node">
<div
className="category-line"
style={{ paddingLeft: `${depth * 17}px` }}
>
{children.length ? (
<button
className="icon-button"
aria-label={`${expanded.includes(id) ? "Collapse" : "Expand"} ${category?.name || id}`}
aria-expanded={expanded.includes(id)}
onClick={() =>
setExpanded(
expanded.includes(id)
? expanded.filter((v) => v !== id)
: [...expanded, id],
)
}
>
<ArrowRight
size={13}
style={{
transform: expanded.includes(id)
? "rotate(90deg)"
: undefined,
}}
/>
</button>
) : (
<span className="tree-spacer" />
)}
<button className="category-drill" onClick={() => onSelect(id)}>
<span>{category?.name || id}</span>
<span>
{rows.map((g) => (
<strong className="money" key={g.currency}>
{money(g.amount, g.currency)}
</strong>
))}
</span>
</button>
</div>
{expanded.includes(id) &&
(showAll ? children : children.slice(0, 6)).map((c) =>
node(c.id, depth + 1),
)}
{expanded.includes(id) && !showAll && children.length > 6 && (
<button className="button subtle" onClick={() => setShowAll(true)}>
Show remaining {children.length - 6} categories
</button>
)}
</div>
);
};
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>Category breakdown</h3>
<p>Expand the tree · parents include their descendants</p>
</div>
</div>
{groups.length ? (
<div className="category-tree">{roots.map((c) => node(c.id, 0))}</div>
) : (
<div className="compact-empty">
Your category breakdown appears after importing transactions.
</div>
)}
</section>
);
}
function MonthlyChart({ groups }: { groups: Group[] }) {
if (!groups.length)
return (
<Empty title="Room for a bigger picture">
Your monthly trend appears after importing transactions.
</Empty>
);
const currencies = Array.from(new Set(groups.map((g) => g.currency)));
return (
<div className="monthly-charts">
{currencies.map((currency) => {
const rows = groups
.filter((g) => g.currency === currency)
.sort((a, b) => a.period.localeCompare(b.period));
const max = Math.max(...rows.map((g) => Math.abs(Number(g.amount))), 1);
return (
<div key={currency}>
<span className="eyebrow">{currency}</span>
<div
className="bar-chart"
role="img"
aria-label={`Monthly net cash flow in ${currency}. ${rows.map((g) => `${g.period}: ${g.amount}`).join("; ")}`}
>
{rows.map((g, i) => (
<div className="bar-column" key={`${g.period}-${i}`}>
<span className="bar-value">{money(g.amount, currency)}</span>
<div className="bar-track">
<div
className={`bar ${g.amount.startsWith("-") ? "negative" : ""}`}
style={{
height: `${Math.max((Math.abs(Number(g.amount)) / max) * 100, 1)}%`,
}}
title={`${g.period}: ${money(g.amount, currency)}`}
/>
</div>
<span className="bar-label">{g.period}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
function GroupPanel({
title,
subtitle,
groups,
onSelect,
}: {
title: string;
subtitle: string;
groups: Group[];
onSelect: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const maxima: Record<string, number> = {};
for (const g of groups)
maxima[g.currency] = Math.max(
maxima[g.currency] || 1,
Math.abs(Number(g.amount)),
);
const sorted = [...groups].sort(
(a, b) =>
a.currency.localeCompare(b.currency) ||
Math.abs(Number(b.amount)) - Math.abs(Number(a.amount)),
);
return (
<section className="panel">
<div className="panel-heading">
<div>
<h3>{title}</h3>
<p>{subtitle}</p>
</div>
</div>
{groups.length ? (
<div className="group-list">
{(expanded ? sorted : sorted.slice(0, 6)).map((g, i) => (
<button
className="group-row"
key={`${g.id}-${g.currency}-${i}`}
onClick={() => onSelect(g.id)}
>
<div>
<span className="group-name">{g.name || "Unassigned"}</span>
<strong className="money">{money(g.amount, g.currency)}</strong>
</div>
<div className="group-track">
<span
style={{
width: `${Math.max(1, (Math.abs(Number(g.amount)) / maxima[g.currency]) * 100)}%`,
}}
/>
</div>
<small>
{g.count} transaction{g.count === 1 ? "" : "s"}
</small>
</button>
))}
{groups.length > 6 && (
<button
className="button subtle"
onClick={() => setExpanded(!expanded)}
>
{expanded ? "Show less" : `Show all ${groups.length}`}
</button>
)}
</div>
) : (
<div className="compact-empty">No activity in this view.</div>
)}
</section>
);
}
+473
View File
@@ -0,0 +1,473 @@
import { useState } from "react";
import {
Plus,
Pencil,
GitMerge,
Trash2,
FolderTree,
Tag as TagIcon,
Store,
ChevronRight,
} from "lucide-react";
import type { Category, Dataset, Merchant, Tag } from "./api";
import { categoryPath } from "./api";
import {
CategoryOptions,
Empty,
ErrorMessage,
Field,
FormActions,
Modal,
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
type Entity = "category" | "tag" | "merchant";
type Item = Category | Tag | Merchant;
const titles = { category: "Categories", tag: "Tags", merchant: "Merchants" };
const plurals = { category: "categories", tag: "tags", merchant: "merchants" };
export function Registry({
entity,
data,
mutate,
}: {
entity: Entity;
data: Dataset;
mutate: Mutate;
}) {
const [editing, setEditing] = useState<Item | null>(null);
const [action, setAction] = useState<{
item: Item;
action: "merge" | "delete";
} | null>(null);
const items: Item[] =
data[plurals[entity] as "categories" | "tags" | "merchants"];
const create = () =>
setEditing(
entity === "category"
? { id: "", name: "", parent_id: "cat_expenses", kind: "expense" }
: entity === "merchant"
? {
id: "",
name: "",
aliases: [],
default_tag_ids: [],
use_defaults: false,
}
: { id: "", name: "" },
);
const row = (item: Item, depth = 0) => (
<div className="registry-row" key={item.id}>
<div
className="registry-label"
style={{ paddingLeft: `${depth * 23}px` }}
>
{entity === "category" ? (
<FolderTree size={18} />
) : entity === "tag" ? (
<TagIcon size={18} />
) : (
<Store size={18} />
)}
<div>
<strong>{item.name}</strong>
{"kind" in item && (
<small>
{item.kind}
{!item.parent_id ? " root" : ""}
</small>
)}
{"aliases" in item && (
<small>
{item.aliases.length ? item.aliases.join(" · ") : "No aliases"}
{item.use_defaults ? " · Defaults enabled" : ""}
</small>
)}
</div>
</div>
{"default_category_id" in item && item.default_category_id && (
<span className="muted registry-detail">
{categoryPath(data, item.default_category_id)}
</span>
)}
<div className="row-actions">
<button
className="icon-button"
title={`Edit ${item.name}`}
aria-label={`Edit ${item.name}`}
onClick={() => setEditing(item)}
>
<Pencil size={16} />
</button>
<button
className="icon-button"
title={`Merge ${item.name}`}
aria-label={`Merge ${item.name}`}
onClick={() => setAction({ item, action: "merge" })}
>
<GitMerge size={16} />
</button>
<button
className="icon-button danger"
title={`Delete ${item.name}`}
aria-label={`Delete ${item.name}`}
onClick={() => setAction({ item, action: "delete" })}
>
<Trash2 size={16} />
</button>
</div>
</div>
);
const tree = (
parent: string | undefined,
depth = 0,
visited = new Set<string>(),
): React.ReactNode =>
data.categories
.filter(
(c) => (c.parent_id || "") === (parent || "") && !visited.has(c.id),
)
.map((c) => (
<div key={c.id}>
{row(c, depth)}
{tree(c.id, depth + 1, new Set([...visited, c.id]))}
</div>
));
return (
<>
<div className="section-heading">
<div>
<h2>{titles[entity]}</h2>
<p>
{entity === "category"
? "A clear home for every transaction. Parent categories roll up their children."
: entity === "tag"
? "Flexible labels that work across your accounts and categories."
: "Recognize familiar names and choose explicit classification defaults."}
</p>
</div>
<button className="button primary" onClick={create}>
<Plus size={17} />
New {entity}
</button>
</div>
<section className="panel registry">
{items.length ? (
entity === "category" ? (
tree(undefined)
) : (
items.map((item) => row(item))
)
) : (
<Empty title={`No ${titles[entity].toLowerCase()} yet`}>
Create your first {entity} to organize transactions.
</Empty>
)}
</section>
{editing && (
<RegistryEditor
key={editing.id}
entity={entity}
item={editing}
data={data}
mutate={mutate}
close={() => setEditing(null)}
/>
)}{" "}
{action && (
<ManageDialog
entity={entity}
item={action.item}
action={action.action}
data={data}
mutate={mutate}
close={() => setAction(null)}
/>
)}
</>
);
}
function RegistryEditor({
entity,
item,
data,
mutate,
close,
}: {
entity: Entity;
item: Item;
data: Dataset;
mutate: Mutate;
close: () => void;
}) {
const [name, setName] = useState(item.name);
const [kind, setKind] = useState("kind" in item ? item.kind : "expense");
const [parent, setParent] = useState(
"parent_id" in item ? item.parent_id || "" : "",
);
const merchant = "aliases" in item ? item : null;
const [aliases, setAliases] = useState(merchant?.aliases.join("\n") || "");
const [category, setCategory] = useState(merchant?.default_category_id || "");
const [tags, setTags] = useState(merchant?.default_tag_ids || []);
const [defaults, setDefaults] = useState(merchant?.use_defaults || false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const descendants = new Set([item.id]);
let changed = true;
while (changed) {
changed = false;
for (const c of data.categories)
if (
c.parent_id &&
descendants.has(c.parent_id) &&
!descendants.has(c.id)
) {
descendants.add(c.id);
changed = true;
}
}
return (
<Modal title={`${item.id ? "Edit" : "New"} ${entity}`} close={close}>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
const result =
entity === "category"
? { id: item.id, name: name.trim(), kind, parent_id: parent }
: entity === "merchant"
? {
id: item.id,
name: name.trim(),
aliases: Array.from(
new Set(
aliases
.split("\n")
.map((a) => a.trim())
.filter(Boolean),
),
),
default_category_id: category,
default_tag_ids: tags,
use_defaults: defaults,
}
: { id: item.id, name: name.trim() };
await mutate(
`/api/${plurals[entity]}`,
{ [entity]: result },
`${name.trim()} saved`,
);
close();
} catch (err) {
setError(String(err instanceof Error ? err.message : err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<Field label="Name">
<input
required
maxLength={200}
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</Field>
{entity === "category" && (
<>
<Field label="Kind">
<select
value={kind}
onChange={(e) => {
setKind(e.target.value);
setParent("");
}}
>
<option value="expense">Expense</option>
<option value="income">Income</option>
</select>
</Field>
<Field label="Parent category">
<select
value={parent}
onChange={(e) => setParent(e.target.value)}
>
<option value="">No parent (root)</option>
<CategoryOptions
data={data}
kind={kind}
exclude={[...descendants]}
/>
</select>
</Field>
<p className="muted">
Changing the parent moves this category and its entire subtree.
The server protects fallback categories and validates
references.
</p>
</>
)}
{entity === "merchant" && (
<>
<Field
label="Aliases"
hint="One exact merchant alias per line. These help recognize future transactions."
>
<textarea
rows={4}
value={aliases}
onChange={(e) => setAliases(e.target.value)}
/>
</Field>
<label className="checkbox">
<input
type="checkbox"
checked={defaults}
onChange={(e) => setDefaults(e.target.checked)}
/>
Use these defaults when this merchant is recognized
</label>
<Field label="Default category">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
>
<option value="">No default category</option>
<CategoryOptions data={data} />
</select>
</Field>
<TagPicker data={data} value={tags} onChange={setTags} />
<p className="muted">
Defaults are only used when explicitly enabled. Editing defaults
does not rewrite existing transactions.
</p>
</>
)}
</div>
<FormActions busy={busy} close={close} />
</form>
</Modal>
);
}
function ManageDialog({
entity,
item,
action,
data,
mutate,
close,
}: {
entity: Entity;
item: Item;
action: "delete" | "merge";
data: Dataset;
mutate: Mutate;
close: () => void;
}) {
const [target, setTarget] = useState("");
const [confirm, setConfirm] = useState(false);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const items: Item[] =
data[plurals[entity] as "categories" | "tags" | "merchants"];
return (
<Modal
title={`${action === "merge" ? "Merge" : "Delete"} ${item.name}`}
close={close}
>
<form
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
"/api/manage",
{ entity, action, id: item.id, target_id: target },
`${item.name} ${action === "merge" ? "merged" : "deleted"}`,
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<p>
{action === "merge"
? "References will move to the destination and the source will be removed. Review the destination carefully."
: entity === "tag"
? "This tag will be removed from every transaction and merchant default. The original bank facts will not change."
: entity === "category"
? "Referenced categories need a replacement. Protected roots and unsafe tree changes cannot be deleted."
: "Remove this merchant from your registry. Referenced merchants may require a merge instead."}
</p>
{(action === "merge" || entity === "category") && (
<Field
label={
action === "merge"
? "Merge into"
: "Replacement category (if referenced)"
}
>
<select
required={action === "merge"}
value={target}
onChange={(e) => setTarget(e.target.value)}
>
<option value="">Choose destination</option>
{items
.filter((i) => i.id !== item.id)
.map((i) => (
<option value={i.id} key={i.id}>
{entity === "category"
? categoryPath(data, i.id)
: i.name}
</option>
))}
</select>
</Field>
)}
<label className="checkbox">
<input
required
type="checkbox"
checked={confirm}
onChange={(e) => setConfirm(e.target.checked)}
/>
I understand this changes all references and cannot be undone here.
</label>
</div>
<div className="form-actions">
<button
type="button"
className="button secondary"
disabled={busy}
onClick={close}
>
Cancel
</button>
<button className="button destructive" disabled={busy || !confirm}>
{action === "merge" ? (
<ChevronRight size={16} />
) : (
<Trash2 size={16} />
)}{" "}
{busy
? "Working…"
: action === "merge"
? "Merge permanently"
: "Delete permanently"}
</button>
</div>
</form>
</Modal>
);
}
+250
View File
@@ -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>
);
}
+404
View File
@@ -0,0 +1,404 @@
import { useMemo, useState } from "react";
import {
Search,
ArrowUpRight,
ArrowDownLeft,
ArrowLeftRight,
ChevronLeft,
ChevronRight,
SlidersHorizontal,
} from "lucide-react";
import type { Dataset, Enrichment, Filter, Transaction } from "./api";
import { categoryPath, money } from "./api";
import {
CategoryOptions,
Empty,
ErrorMessage,
Field,
Filters,
FormActions,
Modal,
TagPicker,
} from "./ui";
import type { Mutate } from "./ui";
export function Transactions({
data,
filter,
setFilter,
mutate,
}: {
data: Dataset;
filter: Filter;
setFilter: (f: Filter) => void;
mutate: Mutate;
}) {
const [query, setQuery] = useState("");
const [editing, setEditing] = useState<Transaction | null>(null);
const [page, setPage] = useState(0);
const filtered = useMemo(() => {
const categories = new Set(filter.category_id ? [filter.category_id] : []);
let changed = true;
while (changed) {
changed = false;
for (const c of data.categories)
if (
c.parent_id &&
categories.has(c.parent_id) &&
!categories.has(c.id)
) {
categories.add(c.id);
changed = true;
}
}
return data.transactions
.filter(
({ facts: f, enrichment: e }) =>
(!filter.from || f.booking_date >= filter.from) &&
(!filter.to || f.booking_date <= filter.to) &&
(!filter.currency || f.currency === filter.currency) &&
(!filter.account_id || f.account_id === filter.account_id) &&
(!filter.category_id || categories.has(e.category_id || "")) &&
(!filter.tag_id || e.tag_ids.includes(filter.tag_id)) &&
(!filter.merchant_id || e.merchant_id === filter.merchant_id) &&
(!query ||
`${f.raw_description} ${f.counterparty || ""} ${data.merchants.find((m) => m.id === e.merchant_id)?.name || ""} ${f.amount}`
.toLowerCase()
.includes(query.toLowerCase())),
)
.sort(
(a, b) =>
b.facts.booking_date.localeCompare(a.facts.booking_date) ||
a.facts.id.localeCompare(b.facts.id),
);
}, [data, filter, query]);
const currentPage = Math.min(
page,
Math.max(0, Math.ceil(filtered.length / 40) - 1),
);
return (
<>
<div className="section-heading">
<div>
<h2>Transactions</h2>
<p>Your bank facts stay untouched. Make the meaning your own.</p>
</div>
<span className="badge neutral">{filtered.length} transactions</span>
</div>
<Filters
data={data}
value={filter}
onChange={(f) => {
setFilter(f);
setPage(0);
}}
/>
<section className="panel">
<div className="panel-toolbar">
<label className="search">
<Search size={18} />
<input
aria-label="Search transactions"
placeholder="Search description, merchant or amount…"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setPage(0);
}}
/>
</label>
<span className="muted small">
<SlidersHorizontal size={15} /> Click a transaction to edit
</span>
</div>
{filtered.length ? (
<>
<div className="table-scroll">
<table>
<thead>
<tr>
<th>Date / account</th>
<th>Transaction</th>
<th>Category / tags</th>
<th>Source</th>
<th className="numeric">Amount</th>
</tr>
</thead>
<tbody>
{filtered
.slice(currentPage * 40, currentPage * 40 + 40)
.map((tx) => {
const { facts: f, enrichment: e } = tx;
return (
<tr key={f.id}>
<td>
<span className="nowrap">{f.booking_date}</span>
<small>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</small>
</td>
<td>
<button
className="transaction-link"
onClick={() => setEditing(tx)}
>
<span className={`transaction-icon ${e.kind}`}>
{e.kind === "transfer" ? (
<ArrowLeftRight size={17} />
) : f.amount.startsWith("-") ? (
<ArrowUpRight size={17} />
) : (
<ArrowDownLeft size={17} />
)}
</span>
<span>
<strong>
{data.merchants.find(
(m) => m.id === e.merchant_id,
)?.name ||
f.counterparty ||
"Bank transaction"}
</strong>
<small className="description">
{f.raw_description}
</small>
</span>
</button>
</td>
<td>
<span>
{e.kind === "transfer"
? "Own-account transfer"
: categoryPath(data, e.category_id)}
</span>
<div className="chips">
{e.tag_ids.map((id) => (
<span className="badge" key={id}>
{data.tags.find((t) => t.id === id)?.name ||
id}
</span>
))}
</div>
</td>
<td>
<span className="badge neutral">
{e.classification.source}
</span>
{e.classification.error && (
<small className="text-danger">
Classification error
</small>
)}
</td>
<td
className={`numeric money ${f.amount.startsWith("-") ? "" : "positive"}`}
>
{money(f.amount, f.currency)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="pagination">
<span>
{currentPage * 40 + 1}
{Math.min((currentPage + 1) * 40, filtered.length)} of{" "}
{filtered.length}
</span>
<div>
<button
className="icon-button"
aria-label="Previous page"
disabled={currentPage === 0}
onClick={() => setPage(currentPage - 1)}
>
<ChevronLeft size={18} />
</button>
<span>Page {currentPage + 1}</span>
<button
className="icon-button"
aria-label="Next page"
disabled={(currentPage + 1) * 40 >= filtered.length}
onClick={() => setPage(currentPage + 1)}
>
<ChevronRight size={18} />
</button>
</div>
</div>
</>
) : (
<Empty
title={
data.transactions.length
? "No matching transactions"
: "Your transaction story starts here"
}
>
{data.transactions.length
? "Try a wider date range or clear your filters."
: "Add an account, then import an N26 CSV or connect your bank from Accounts."}
</Empty>
)}
</section>
{editing && (
<TransactionEditor
data={data}
transaction={editing}
mutate={mutate}
close={() => setEditing(null)}
/>
)}
</>
);
}
function TransactionEditor({
data,
transaction,
mutate,
close,
}: {
data: Dataset;
transaction: Transaction;
mutate: Mutate;
close: () => void;
}) {
const [value, setValue] = useState<Enrichment>({
...transaction.enrichment,
tag_ids: [...transaction.enrichment.tag_ids],
});
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
const f = transaction.facts;
const peer = data.transactions.find(
(t) => t.facts.id === value.transfer_peer_id,
);
return (
<Modal title="Transaction details" close={close} wide>
<form
onSubmit={async (event) => {
event.preventDefault();
setBusy(true);
setError("");
try {
await mutate(
`/api/transactions/${encodeURIComponent(f.id)}`,
{
enrichment: { ...value, classification: { source: "manual" } },
},
"Transaction updated",
);
close();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}}
>
<div className="form-body">
<ErrorMessage error={error} />
<div className="transaction-summary">
<div>
<span className="eyebrow">{f.booking_date}</span>
<h3>{f.counterparty || f.raw_description}</h3>
<p>
{data.accounts.find((a) => a.id === f.account_id)
?.display_name || f.account_id}
</p>
</div>
<strong className="large-money">
{money(f.amount, f.currency)}
</strong>
</div>
<div className="two-columns">
<Field
label="Kind"
hint="Derived from the bank amount and verified transfer links."
>
<input value={value.kind} readOnly />
</Field>
<Field label="Merchant">
<select
value={value.merchant_id || ""}
onChange={(e) =>
setValue({ ...value, merchant_id: e.target.value })
}
>
<option value="">No merchant</option>
{data.merchants.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
</Field>
</div>
{value.kind === "transfer" ? (
<Field label="Linked opposite transaction">
<input
readOnly
value={
peer
? `${peer.facts.booking_date} · ${money(peer.facts.amount, peer.facts.currency)} · ${peer.facts.raw_description}`
: value.transfer_peer_id || "No counterpart supplied"
}
/>
</Field>
) : (
<Field label="Category">
<select
required
value={value.category_id || ""}
onChange={(e) =>
setValue({ ...value, category_id: e.target.value })
}
>
<option value="">Choose category</option>
<CategoryOptions data={data} kind={value.kind} />
</select>
</Field>
)}
<TagPicker
data={data}
value={value.tag_ids}
onChange={(tag_ids) => setValue({ ...value, tag_ids })}
/>
<details open>
<summary>
Original bank facts{" "}
<span className="badge neutral">Read only</span>
</summary>
<dl className="facts">
{Object.entries(f).map(([key, text]) => (
<div key={key}>
<dt>{key.replaceAll("_", " ")}</dt>
<dd>{text || "—"}</dd>
</div>
))}
</dl>
</details>
<details open>
<summary>Classification provenance</summary>
<dl className="facts">
{Object.entries(transaction.enrichment.classification).map(
([key, text]) => (
<div key={key}>
<dt>{key}</dt>
<dd>{text || "—"}</dd>
</div>
),
)}
</dl>
<p className="muted small">
Saving records this classification as a manual edit. The original
bank facts are never submitted.
</p>
</details>
</div>
<FormActions busy={busy} close={close} />
</form>
</Modal>
);
}
+248
View File
@@ -0,0 +1,248 @@
export interface Account {
id: string;
display_name: string;
institution: string;
currency: string;
external_account_id?: string;
iban?: string;
active: boolean;
}
export interface Facts {
id: string;
source: string;
account_id: string;
booking_date: string;
value_date?: string;
amount: string;
currency: string;
raw_description: string;
external_id?: string;
fingerprint: string;
counterparty?: string;
counterparty_iban?: string;
}
export interface Provenance {
source: string;
model?: string;
timestamp?: string;
error?: string;
}
export interface Enrichment {
kind: string;
merchant_id?: string;
category_id?: string;
tag_ids: string[];
transfer_peer_id?: string;
classification: Provenance;
}
export interface Transaction {
facts: Facts;
enrichment: Enrichment;
}
export interface Category {
id: string;
name: string;
parent_id?: string;
kind: string;
}
export interface Tag {
id: string;
name: string;
}
export interface Merchant {
id: string;
name: string;
aliases: string[];
default_category_id?: string;
default_tag_ids: string[];
use_defaults: boolean;
}
export interface Dataset {
accounts: Account[];
categories: Category[];
tags: Tag[];
merchants: Merchant[];
transactions: Transaction[];
}
export interface Connection {
account_id: string;
institution: string;
country: string;
status: "local" | "connected" | "reconnect_required" | "error";
valid_until: string;
error: string;
}
export interface State {
data: Dataset;
revision: string;
callback_url: string;
connections: Connection[];
status: {
sync_error: string;
index_error: string;
last_sync: string;
banking_configured: boolean;
ai_configured: boolean;
};
settings: { model: string; include_amount: boolean };
sessions: { session_id: string; valid_until: string; accounts: Account[] }[];
}
export interface Total {
currency: string;
expenses: string;
income: string;
net: string;
}
export interface Group {
id: string;
name: string;
currency: string;
period: string;
amount: string;
count: number;
}
export interface Dashboard {
totals: Total[];
previous: Total[];
monthly: Group[];
categories: Group[];
tags: Group[];
merchants: Group[];
accounts: Group[];
recurring: Group[];
}
export interface Filter {
from: string;
to: string;
currency: string;
account_id: string;
category_id: string;
tag_id: string;
merchant_id: string;
}
export interface Preview {
id: string;
revision: string;
new_merchants: Merchant[];
changes: {
id: string;
description: string;
before: Enrichment;
after: Enrichment;
}[];
analysed: number;
unchanged: number;
errors: { id: string; error: string }[];
}
export class APIError extends Error {
constructor(
message: string,
public status: number,
) {
super(message);
}
}
export async function request<T>(
path: string,
body?: unknown,
signal?: AbortSignal,
): Promise<T> {
const multipart = body instanceof FormData;
const response = await fetch(path, {
method: body === undefined ? "GET" : "POST",
headers:
body === undefined || multipart
? undefined
: { "Content-Type": "application/json" },
body:
body === undefined ? undefined : multipart ? body : JSON.stringify(body),
signal,
});
const text = await response.text();
let data: unknown;
try {
data = text ? JSON.parse(text) : null;
} catch {
throw new APIError(
`Server returned an unreadable response (${response.status}).`,
response.status,
);
}
if (!response.ok)
throw new APIError(
typeof data === "object" && data && "error" in data
? String(data.error)
: `Request failed (${response.status}).`,
response.status,
);
if (data === null)
throw new APIError(
"The server returned an empty response.",
response.status,
);
return data as T;
}
export function normalizeState(state: State): State {
// Go can encode empty slices as null; missing registry fields are an incompatible response.
if (
!state ||
!state.data ||
typeof state.revision !== "string" ||
!state.status ||
!state.settings ||
!("sessions" in state) ||
!("connections" in state) ||
typeof state.callback_url !== "string"
)
throw new Error("The server returned an incompatible state response.");
for (const key of [
"accounts",
"categories",
"tags",
"merchants",
"transactions",
] as const) {
if (!(key in state.data))
throw new Error(`The server state is missing ${key}.`);
if (state.data[key] === null) Object.assign(state.data, { [key]: [] });
else if (!Array.isArray(state.data[key]))
throw new Error(`The server state has invalid ${key}.`);
}
for (const tx of state.data.transactions) tx.enrichment.tag_ids ??= [];
for (const merchant of state.data.merchants) {
merchant.aliases ??= [];
merchant.default_tag_ids ??= [];
}
state.sessions ??= [];
state.connections ??= [];
for (const session of state.sessions) session.accounts ??= [];
return state;
}
export function money(value: string, currency: string): string {
// Keep all financial values as decimal strings, including display formatting.
const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(value);
if (!match) return `${value} ${currency}`;
const decimals = (match[3] || "").replace(/0+$/, "").padEnd(2, "0");
return `${match[1] === "-" ? "" : ""}${match[2].replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${decimals} ${currency}`;
}
export function categoryPath(data: Dataset, id?: string): string {
if (!id) return "No category";
const names: string[] = [];
const seen = new Set<string>();
let current = data.categories.find((c) => c.id === id);
while (current && !seen.has(current.id)) {
seen.add(current.id);
names.unshift(current.name);
current = data.categories.find((c) => c.id === current?.parent_id);
}
return names.length ? names.join(" / ") : `Unknown category (${id})`;
}
export const emptyFilter: Filter = {
from: "",
to: "",
currency: "",
account_id: "",
category_id: "",
tag_id: "",
merchant_id: "",
};
+418
View File
@@ -0,0 +1,418 @@
import React, { useCallback, useEffect, useState } from "react";
import { createRoot } from "react-dom/client";
import {
LayoutDashboard,
ArrowLeftRight,
FolderTree,
Tags,
Store,
Wallet,
Sparkles,
Settings as SettingsIcon,
RefreshCw,
Menu,
X,
ShieldCheck,
CheckCircle2,
CircleHelp,
} from "lucide-react";
import type { State } from "./api";
import { APIError, emptyFilter, normalizeState, request } from "./api";
import { Overview } from "./Overview";
import { Transactions } from "./Transactions";
import { Registry } from "./Registry";
import { Accounts } from "./Accounts";
import { Classification } from "./Classification";
import { Settings } from "./Settings";
import { ErrorMessage } from "./ui";
import "./styles.css";
const navigation = [
{ id: "overview", label: "Overview", icon: LayoutDashboard },
{ id: "transactions", label: "Transactions", icon: ArrowLeftRight },
{ id: "categories", label: "Categories", icon: FolderTree },
{ id: "tags", label: "Tags", icon: Tags },
{ id: "merchants", label: "Merchants", icon: Store },
{ id: "accounts", label: "Accounts", icon: Wallet },
{ id: "classification", label: "AI classification", icon: Sparkles },
{ id: "settings", label: "Settings", icon: SettingsIcon },
];
function App() {
const [page, setPage] = useState(() =>
navigation.some((n) => n.id === window.location.hash.slice(1))
? window.location.hash.slice(1)
: "overview",
);
const [state, setState] = useState<State | null>(null);
const [error, setError] = useState("");
const [conflict, setConflict] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [notice, setNotice] = useState("");
const [mobileNav, setMobileNav] = useState(false);
const [filter, setFilter] = useState({ ...emptyFilter });
const acceptState = useCallback((value: State, message?: string) => {
setState(normalizeState(value));
setConflict(false);
if (message) setNotice(message);
}, []);
const reload = useCallback(async () => {
setRefreshing(true);
setError("");
try {
acceptState(await request<State>("/api/state"));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRefreshing(false);
}
}, [acceptState]);
useEffect(() => {
void reload();
const change = () => {
const next = window.location.hash.slice(1);
if (navigation.some((n) => n.id === next)) setPage(next);
};
window.addEventListener("hashchange", change);
return () => window.removeEventListener("hashchange", change);
}, [reload]);
useEffect(() => {
if (!notice) return;
const timeout = window.setTimeout(() => setNotice(""), 7000);
return () => window.clearTimeout(timeout);
}, [notice]);
useEffect(() => {
const connected = new URLSearchParams(window.location.search).get(
"connected",
);
if (connected === "1") {
setNotice(
"Bank authorization completed. Your connection is available in Accounts.",
);
window.history.replaceState(
null,
"",
`${window.location.pathname}${window.location.hash}`,
);
}
}, []);
const navigate = (next: string) => {
setPage(next);
window.location.hash = next;
setMobileNav(false);
window.scrollTo({ top: 0, behavior: "instant" });
};
const mutate = async (
path: string,
body: Record<string, unknown>,
message?: string,
) => {
if (!state) throw new Error("Load the journal before making changes.");
const revisionless = [
"/api/settings",
"/api/sync",
"/api/rebuild",
].includes(path);
try {
acceptState(
await request<State>(
path,
revisionless ? body : { revision: state.revision, ...body },
),
message,
);
} catch (err) {
if (err instanceof APIError && err.status === 409) setConflict(true);
throw err;
}
};
const reconnects =
state?.connections.filter(
(connection) => connection.status === "reconnect_required",
) || [];
return (
<div className="app">
<a className="skip-link" href="#main-content">
Skip to content
</a>
{mobileNav && (
<button
className="nav-backdrop"
aria-label="Close navigation"
onClick={() => setMobileNav(false)}
/>
)}
<aside className={`sidebar ${mobileNav ? "open" : ""}`}>
<a
href="#overview"
className="brand"
onClick={(e) => {
e.preventDefault();
navigate("overview");
}}
>
<span className="brand-mark" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
<path
d="M6 17c0-4 3-6 7-6V7c0-3 2-5 5-5s5 2 5 5v3h3v3h-5c0 7-4 11-10 11-5 0-8-3-8-7h3Z"
fill="currentColor"
/>
<circle cx="19" cy="7" r="1.2" fill="#102131" />
</svg>
</span>
<span>
finance<span className="brand-light">duck</span>
<small>YOUR MONEY, CLEARLY</small>
</span>
</a>
<span className="nav-label">WORKSPACE</span>
<nav aria-label="Main navigation">
{navigation.map(({ id, label, icon: Icon }, i) => (
<button
key={id}
className={`nav-item ${page === id ? "active" : ""} ${i === 7 ? "nav-settings" : ""}`}
aria-current={page === id ? "page" : undefined}
onClick={() => navigate(id)}
>
<Icon size={19} />
<span>{label}</span>
{id === "classification" && <span className="nav-ai">AI</span>}
</button>
))}
</nav>
<div className="sidebar-bottom">
<ShieldCheck size={19} />
<div>
<strong>Private by nature</strong>
<span>Self-hosted. In your hands.</span>
</div>
</div>
</aside>
<div className="main-shell">
<header className="topbar">
<div className="breadcrumb">
<button
className="icon-button mobile-toggle"
aria-label="Open navigation"
onClick={() => setMobileNav(true)}
>
<Menu size={21} />
</button>
<span>Workspace</span>
<span className="breadcrumb-divider">/</span>
<strong>{navigation.find((n) => n.id === page)?.label}</strong>
</div>
<div className="topbar-actions">
<span className="local-status">
<span />
Local workspace
</span>
<button
className="icon-button"
title="Refresh journal and revision"
aria-label="Refresh journal and revision"
disabled={refreshing}
onClick={() => void reload()}
>
<RefreshCw size={18} className={refreshing ? "spin" : ""} />
</button>
<button
className="avatar"
title="Open privacy settings"
aria-label="Open privacy settings"
onClick={() => navigate("settings")}
>
<ShieldCheck size={18} />
</button>
</div>
</header>
<main id="main-content" tabIndex={-1}>
{notice && (
<div className="toast" role="status">
<CheckCircle2 size={18} />
<span>{notice}</span>
<button
className="icon-button"
aria-label="Dismiss notification"
onClick={() => setNotice("")}
>
<X size={16} />
</button>
</div>
)}
<ErrorMessage error={error} />
{conflict && (
<div className="alert error">
<div>
<strong>Your journal has changed.</strong>
<p>
The conflicting change was not applied. Reloading closes stale
edit forms so you can edit the latest version.
</p>
</div>
<button
className="button secondary"
onClick={() => void reload()}
>
Reload latest revision
</button>
</div>
)}
{reconnects.length > 0 && (
<div className="alert warning">
<CircleHelp size={19} />
<span>
{Array.from(new Set(reconnects.map((c) => c.institution))).join(
", ",
)}{" "}
needs reconnection. Your existing transactions are safe.
</span>
<button
className="button secondary"
onClick={() => navigate("accounts")}
>
Reconnect bank
</button>
</div>
)}
{state &&
(state.status.sync_error || state.status.index_error) &&
page !== "settings" && (
<div className="alert warning">
<CircleHelp size={19} />
<span>
{state.status.index_error
? `Analytics needs attention: ${state.status.index_error}`
: `Bank sync needs attention: ${state.status.sync_error}`}
</span>
<button
className="button subtle"
onClick={() => navigate("settings")}
>
View status
</button>
</div>
)}
{!state ? (
<div className="loading-block">
{refreshing ? (
<>
<span className="spinner" />
<span role="status">Opening your workspace</span>
</>
) : (
<button
className="button primary"
onClick={() => void reload()}
>
Retry loading workspace
</button>
)}
</div>
) : (
<>
{page === "overview" && (
<Overview
data={state.data}
revision={state.revision}
filter={filter}
setFilter={setFilter}
navigate={navigate}
/>
)}
{page === "transactions" && (
<Transactions
key={state.revision}
data={state.data}
filter={filter}
setFilter={setFilter}
mutate={mutate}
/>
)}
{page === "categories" && (
<Registry
key={`categories-${state.revision}`}
entity="category"
data={state.data}
mutate={mutate}
/>
)}
{page === "tags" && (
<Registry
key={`tags-${state.revision}`}
entity="tag"
data={state.data}
mutate={mutate}
/>
)}
{page === "merchants" && (
<Registry
key={`merchants-${state.revision}`}
entity="merchant"
data={state.data}
mutate={mutate}
/>
)}
{page === "accounts" && (
<Accounts
key={state.revision}
state={state}
mutate={mutate}
acceptState={acceptState}
/>
)}
{page === "classification" && (
<Classification state={state} acceptState={acceptState} />
)}
{page === "settings" && (
<Settings
key={`${state.settings.model}-${state.settings.include_amount}`}
state={state}
mutate={mutate}
/>
)}
</>
)}
<footer>
<span>Finance Duck</span>
<span>Clarity without compromise.</span>
</footer>
</main>
</div>
</div>
);
}
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ message: string }
> {
state = { message: "" };
static getDerivedStateFromError(error: Error) {
return { message: error.message };
}
render() {
return this.state.message ? (
<div className="fatal">
<h1>We couldn't display this workspace</h1>
<p>
The server response or application needs attention. Your journal has
not been modified by this display error.
</p>
<ErrorMessage error={this.state.message} />
<button
className="button primary"
onClick={() => window.location.reload()}
>
Reload workspace
</button>
</div>
) : (
this.props.children
);
}
}
createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>,
);
+2084
View File
File diff suppressed because it is too large Load Diff
+278
View File
@@ -0,0 +1,278 @@
import { useEffect, useId, useRef } from "react";
import type { ReactNode } from "react";
import { X, Inbox, AlertCircle } from "lucide-react";
import type { Dataset, Filter } from "./api";
import { categoryPath, emptyFilter } from "./api";
export function Modal({
title,
children,
close,
wide = false,
}: {
title: string;
children: ReactNode;
close: () => void;
wide?: boolean;
}) {
const ref = useRef<HTMLDialogElement>(null);
const titleID = useId();
useEffect(() => {
const dialog = ref.current;
dialog?.showModal();
return () => dialog?.close();
}, []);
return (
<dialog
ref={ref}
aria-labelledby={titleID}
className={wide ? "modal wide" : "modal"}
onCancel={(e) => {
e.preventDefault();
close();
}}
>
<div className="modal-header">
<h2 id={titleID}>{title}</h2>
<button
className="icon-button"
aria-label="Close dialog"
onClick={close}
>
<X size={20} />
</button>
</div>
{children}
</dialog>
);
}
export function Field({
label,
children,
hint,
}: {
label: string;
children: ReactNode;
hint?: string;
}) {
return (
<label className="field">
<span>{label}</span>
{children}
{hint && <small>{hint}</small>}
</label>
);
}
export function ErrorMessage({ error }: { error: string }) {
return error ? (
<div className="alert error" role="alert">
<AlertCircle size={18} />
<span>{error}</span>
</div>
) : null;
}
export function Empty({
title,
children,
}: {
title: string;
children?: ReactNode;
}) {
return (
<div className="empty">
<div className="empty-icon">
<Inbox size={30} />
</div>
<h3>{title}</h3>
<div>{children}</div>
</div>
);
}
export function TagPicker({
data,
value,
onChange,
}: {
data: Dataset;
value: string[];
onChange: (ids: string[]) => void;
}) {
return (
<fieldset className="tag-picker">
<legend>Tags</legend>
{data.tags.length ? (
data.tags.map((tag) => (
<label className="check-chip" key={tag.id}>
<input
type="checkbox"
checked={value.includes(tag.id)}
onChange={(e) =>
onChange(
e.target.checked
? [...value, tag.id]
: value.filter((id) => id !== tag.id),
)
}
/>
{tag.name}
</label>
))
) : (
<small>No tags yet. Create them in Tags.</small>
)}
</fieldset>
);
}
export function CategoryOptions({
data,
kind,
exclude = [],
}: {
data: Dataset;
kind?: string;
exclude?: string[];
}) {
return (
<>
{data.categories
.filter((c) => (!kind || c.kind === kind) && !exclude.includes(c.id))
.map((c) => (
<option key={c.id} value={c.id}>
{categoryPath(data, c.id)}
</option>
))}
</>
);
}
export function Filters({
data,
value,
onChange,
}: {
data: Dataset;
value: Filter;
onChange: (filter: Filter) => void;
}) {
const update = (key: keyof Filter, text: string) =>
onChange({ ...value, [key]: text });
const currencies = Array.from(
new Set([
...data.accounts.map((a) => a.currency),
...data.transactions.map((t) => t.facts.currency),
]),
).sort();
return (
<div className="filters">
<Field label="From">
<input
type="date"
value={value.from}
max={value.to || undefined}
onChange={(e) => update("from", e.target.value)}
/>
</Field>
<Field label="To">
<input
type="date"
value={value.to}
min={value.from || undefined}
onChange={(e) => update("to", e.target.value)}
/>
</Field>
<Field label="Currency">
<select
value={value.currency}
onChange={(e) => update("currency", e.target.value)}
>
<option value="">All currencies</option>
{currencies.map((c) => (
<option key={c}>{c}</option>
))}
</select>
</Field>
<Field label="Account">
<select
value={value.account_id}
onChange={(e) => update("account_id", e.target.value)}
>
<option value="">All accounts</option>
{data.accounts.map((a) => (
<option value={a.id} key={a.id}>
{a.display_name}
</option>
))}
</select>
</Field>
<Field label="Category">
<select
value={value.category_id}
onChange={(e) => update("category_id", e.target.value)}
>
<option value="">All categories</option>
<CategoryOptions data={data} />
</select>
</Field>
<Field label="Tag">
<select
value={value.tag_id}
onChange={(e) => update("tag_id", e.target.value)}
>
<option value="">All tags</option>
{data.tags.map((t) => (
<option value={t.id} key={t.id}>
{t.name}
</option>
))}
</select>
</Field>
<Field label="Merchant">
<select
value={value.merchant_id}
onChange={(e) => update("merchant_id", e.target.value)}
>
<option value="">All merchants</option>
{data.merchants.map((m) => (
<option value={m.id} key={m.id}>
{m.name}
</option>
))}
</select>
</Field>
<button
className="button subtle filter-reset"
onClick={() => onChange({ ...emptyFilter })}
>
Reset
</button>
</div>
);
}
export function FormActions({
busy,
close,
label = "Save changes",
}: {
busy: boolean;
close: () => void;
label?: string;
}) {
return (
<div className="form-actions">
<button
type="button"
className="button secondary"
onClick={close}
disabled={busy}
>
Cancel
</button>
<button className="button primary" type="submit" disabled={busy}>
{busy ? "Saving…" : label}
</button>
</div>
);
}
export type Mutate = (
path: string,
body: Record<string, unknown>,
message?: string,
) => Promise<void>;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src", "vite.config.ts"]
}
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: { proxy: { "/api": "http://127.0.0.1:8080" } },
});