init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user