init
This commit is contained in:
@@ -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") }
|
||||
@@ -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>
|
||||
Generated
+1910
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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: "",
|
||||
};
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+278
@@ -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>;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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" } },
|
||||
});
|
||||
Reference in New Issue
Block a user