Category and tag inputs across the transaction editor, Analyse corrections, and merchant defaults now mint missing entries without a detour through the registry pages. A bare name lands under the kind's root, "Parent / Name" targets that parent, and typing an existing name selects it instead of duplicating. Enter only creates when nothing matches, server rejections surface inline in the dropdown, and assignment pickers offer leaf categories only — the shape the server validates. Mutations now return the accepted state so callers can select the id the server just minted, and the revision-keyed remounts on Transactions and the registry pages are gone: they closed the open modal and threw away pending edits the moment any in-modal creation committed.
458 lines
15 KiB
TypeScript
458 lines
15 KiB
TypeScript
import React, { useCallback, useEffect, useState } from "react";
|
|
import { createRoot } from "react-dom/client";
|
|
import {
|
|
LayoutDashboard,
|
|
ArrowLeftRight,
|
|
FolderTree,
|
|
Tags,
|
|
Store,
|
|
CandlestickChart,
|
|
Wallet,
|
|
PiggyBank,
|
|
Sparkles,
|
|
Settings as SettingsIcon,
|
|
RefreshCw,
|
|
Menu,
|
|
X,
|
|
ShieldCheck,
|
|
CheckCircle2,
|
|
CircleHelp,
|
|
} from "lucide-react";
|
|
import type { State } from "./api";
|
|
import {
|
|
APIError,
|
|
defaultFilter,
|
|
localInstant,
|
|
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 Wealth from "./Wealth";
|
|
import { ErrorMessage } from "./ui";
|
|
// Montserrat carries the wordmark. The subsets are bundled rather than fetched
|
|
// from Google Fonts: the Content-Security-Policy serves fonts from 'self' only,
|
|
// and a self-hosted workspace must not phone home to render its own brand.
|
|
import "@fontsource/montserrat/latin-500.css";
|
|
import "@fontsource/montserrat/latin-700.css";
|
|
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: "instruments", label: "Instruments", icon: CandlestickChart },
|
|
{ id: "accounts", label: "Accounts", icon: Wallet },
|
|
{ id: "wealth", label: "Wealth", icon: PiggyBank },
|
|
{ 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(defaultFilter);
|
|
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]);
|
|
const [connectError, setConnectError] = useState("");
|
|
useEffect(() => {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.get("connected") === "1") {
|
|
const unlinkable = Number(params.get("unlinkable") ?? "0");
|
|
setNotice(
|
|
unlinkable > 0
|
|
? `Bank authorization completed. ${unlinkable} shared account${unlinkable === 1 ? "" : "s"} could not be linked (no IBAN or stable identification, or an unsupported currency); the rest are available in Accounts.`
|
|
: "Bank authorization completed. Your connection is available in Accounts.",
|
|
);
|
|
}
|
|
const failed = params.get("connect_error");
|
|
if (failed) setConnectError(failed);
|
|
if (params.get("connected") || failed) {
|
|
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/settings/openrouter",
|
|
"/api/settings/enablebanking",
|
|
"/api/sync",
|
|
"/api/rebuild",
|
|
].includes(path);
|
|
try {
|
|
const next = await request<State>(
|
|
path,
|
|
revisionless ? body : { revision: state.revision, ...body },
|
|
);
|
|
acceptState(next, message);
|
|
return next;
|
|
} 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">
|
|
<img src="/finance-duck-icon.svg" alt="" width={34} height={34} />
|
|
</span>
|
|
<span>
|
|
finance<span className="brand-light">duck</span>
|
|
<small>YOUR MONEY</small>
|
|
</span>
|
|
</a>
|
|
<span className="nav-label">WORKSPACE</span>
|
|
<nav aria-label="Main navigation">
|
|
{navigation.map(({ id, label, icon: Icon }) => (
|
|
<button
|
|
key={id}
|
|
className={`nav-item ${page === id ? "active" : ""} ${id === "settings" ? "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>
|
|
)}
|
|
{connectError && (
|
|
<div className="alert error">
|
|
<CircleHelp size={19} />
|
|
<span>Bank connection failed: {connectError}</span>
|
|
<button
|
|
className="button subtle"
|
|
onClick={() => setConnectError("")}
|
|
>
|
|
Dismiss
|
|
</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}`
|
|
: state.status.sync_retry_at
|
|
? `Bank sync is waiting for your bank's rate limit and retries by itself after ${localInstant(state.status.sync_retry_at)} — ${state.status.sync_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
|
|
data={state.data}
|
|
filter={filter}
|
|
setFilter={setFilter}
|
|
mutate={mutate}
|
|
/>
|
|
)}
|
|
{page === "categories" && (
|
|
<Registry
|
|
entity="category"
|
|
data={state.data}
|
|
mutate={mutate}
|
|
acceptState={acceptState}
|
|
revision={state.revision}
|
|
model={state.settings.model}
|
|
/>
|
|
)}
|
|
{page === "tags" && (
|
|
<Registry entity="tag" data={state.data} mutate={mutate} />
|
|
)}
|
|
{page === "merchants" && (
|
|
<Registry entity="merchant" data={state.data} mutate={mutate} />
|
|
)}
|
|
{page === "instruments" && (
|
|
<Registry
|
|
entity="instrument"
|
|
data={state.data}
|
|
mutate={mutate}
|
|
/>
|
|
)}
|
|
{page === "accounts" && (
|
|
<Accounts
|
|
state={state}
|
|
mutate={mutate}
|
|
acceptState={acceptState}
|
|
/>
|
|
)}
|
|
{page === "wealth" && (
|
|
<Wealth
|
|
revision={state.revision}
|
|
acceptState={acceptState}
|
|
mutate={mutate}
|
|
/>
|
|
)}
|
|
{page === "classification" && (
|
|
<Classification
|
|
state={state}
|
|
acceptState={acceptState}
|
|
mutate={mutate}
|
|
/>
|
|
)}
|
|
{page === "settings" && (
|
|
<Settings
|
|
key={`${state.settings.model}-${state.settings.classify_on_import}-${state.settings.private_names.join(",")}`}
|
|
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>,
|
|
);
|