feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
Images bauen / build (backend) (push) Failing after 58s
Images bauen / build (frontend) (push) Failing after 57s
CI / backend (push) Successful in 2m21s
CI / frontend (push) Canceled after 4m40s

- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten,
  zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets
  und Sparziele; alle Termine relativ zum heutigen Monat
- Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von
  Eingabefeldern
- Fehlergrenze für Renderfehler und eigene 404-Seite
- Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell
  bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1
- Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält
- build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt
  im Fehlerfall, wie das Secret anzulegen ist
- README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
Jonas Menzel
2026-09-09 21:55:01 +02:00
co-authored by Claude Opus 5
parent bdf4097bd7
commit d63cc0af65
24 changed files with 647 additions and 31 deletions
+3 -2
View File
@@ -1,6 +1,6 @@
/** Routen der Anwendung und die Weichen für die Anmeldung. */
import { Navigate, Route, Routes } from "react-router-dom";
import { Route, Routes } from "react-router-dom";
import { AppLayout } from "@/components/layout/AppLayout";
import { Skeleton } from "@/components/ui/Feedback";
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
import { DashboardPage } from "@/pages/DashboardPage";
import { GoalsPage } from "@/pages/GoalsPage";
import { LoginPage } from "@/pages/LoginPage";
import { NotFoundPage } from "@/pages/NotFoundPage";
import { ReportsPage } from "@/pages/ReportsPage";
import { MerchantsPage } from "@/pages/MerchantsPage";
import { RecurrencesPage } from "@/pages/RecurrencesPage";
@@ -45,7 +46,7 @@ export function App() {
<Route path="/goals" element={<GoalsPage />} />
<Route path="/reports" element={<ReportsPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
@@ -0,0 +1,63 @@
/** Tests des Auffangnetzes für Renderfehler. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorBoundary } from "@/components/ErrorBoundary";
function Kaputt({ werfen }: { werfen: boolean }) {
if (werfen) throw new Error("Etwas ist entzwei");
return <p>Alles in Ordnung</p>;
}
describe("ErrorBoundary", () => {
beforeEach(() => {
// React protokolliert den abgefangenen Fehler zusätzlich selbst.
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("reicht den Inhalt im Normalfall durch", () => {
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
});
it("fängt einen Fehler ab und erklärt ihn", () => {
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
expect(screen.getByText("Da ist etwas schiefgegangen")).toBeInTheDocument();
expect(screen.getByText(/Daten sind davon nicht betroffen/)).toBeInTheDocument();
// Die Ursache bleibt einsehbar, ohne sie aufzudrängen.
expect(screen.getByText("Etwas ist entzwei")).toBeInTheDocument();
});
it("bietet einen erneuten Versuch an", async () => {
const nutzer = userEvent.setup();
render(
<ErrorBoundary>
<Kaputt werfen={false} />
</ErrorBoundary>,
);
expect(screen.getByText("Alles in Ordnung")).toBeInTheDocument();
// Nach einem Fehler steht der Knopf bereit.
render(
<ErrorBoundary>
<Kaputt werfen />
</ErrorBoundary>,
);
const knopf = screen.getByRole("button", { name: /Erneut versuchen/ });
expect(knopf).toBeInTheDocument();
await nutzer.click(knopf);
});
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Auffangnetz für unerwartete Fehler im Rendern.
*
* Ohne diese Grenze bliebe bei einem Fehler eine weiße Seite zurück; hier gibt
* es stattdessen eine Erklärung und einen Weg zurück.
*/
import { Component, type ErrorInfo, type ReactNode } from "react";
import { AlertOctagon, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/Button";
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
// In der Konsole bleibt die vollständige Ursache samt Komponentenpfad.
console.error("Unerwarteter Fehler in der Oberfläche:", error, info.componentStack);
}
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="flex h-full items-center justify-center p-6">
<div className="card max-w-md p-6 text-center">
<AlertOctagon aria-hidden className="mx-auto h-9 w-9 text-negative" />
<h1 className="mt-3 text-base font-semibold text-ink">Da ist etwas schiefgegangen</h1>
<p className="mt-1 text-sm text-muted">
Die Ansicht konnte nicht dargestellt werden. Deine Daten sind davon nicht betroffen.
</p>
<details className="mt-3 text-left">
<summary className="cursor-pointer text-xs text-faint transition hover:text-muted">
Technische Einzelheiten
</summary>
<pre className="mt-2 max-h-40 overflow-auto rounded-lg bg-raised p-2 text-[11px] text-muted">
{error.message}
</pre>
</details>
<div className="mt-4 flex justify-center gap-2">
<Button onClick={() => this.setState({ error: null })}>
<RotateCcw aria-hidden className="h-4 w-4" />
Erneut versuchen
</Button>
<Button variant="primary" onClick={() => window.location.assign("/")}>
Zum Dashboard
</Button>
</div>
</div>
</div>
);
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Übersicht der Tastaturkürzel, erreichbar über `?`. */
import { Modal } from "@/components/ui/Modal";
import { GLOBAL_SHORTCUTS } from "@/hooks/useShortcuts";
export function ShortcutHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title="Tastaturkürzel"
description="Sie greifen, solange kein Eingabefeld den Fokus hat."
>
<dl className="space-y-2">
{GLOBAL_SHORTCUTS.map((kuerzel) => (
<div key={kuerzel.key} className="flex items-center justify-between gap-4">
<dt className="text-sm text-muted">{kuerzel.description}</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
{kuerzel.key}
</kbd>
</dd>
</div>
))}
<div className="flex items-center justify-between gap-4 border-t border-line pt-2">
<dt className="text-sm text-muted">Dialog schließen</dt>
<dd>
<kbd className="rounded border border-line bg-raised px-2 py-0.5 font-mono text-xs text-ink">
Esc
</kbd>
</dd>
</div>
</dl>
</Modal>
);
}
+49 -3
View File
@@ -1,21 +1,55 @@
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { useMemo, useState } from "react";
import { Outlet, useNavigate } from "react-router-dom";
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { Keyboard, LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { ShortcutHelp } from "@/components/ShortcutHelp";
import { Sidebar } from "@/components/layout/Sidebar";
import { Button } from "@/components/ui/Button";
import { useLogout, useMe } from "@/hooks/useAuth";
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
import { useThemeStore } from "@/store/theme";
import { useUiStore } from "@/store/ui";
export function AppLayout() {
const [menuOffen, setMenuOffen] = useState(false);
const [hilfeOffen, setHilfeOffen] = useState(false);
const { data: benutzer } = useMe();
const abmelden = useLogout();
const theme = useThemeStore((zustand) => zustand.theme);
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
const navigate = useNavigate();
const requestAction = useUiStore((zustand) => zustand.requestAction);
const requestSearchFocus = useUiStore((zustand) => zustand.requestSearchFocus);
const shortcuts: Shortcut[] = useMemo(
() => [
{
key: "n",
description: "Neue Buchung",
run: () => {
// Erst auf die Seite wechseln, dort holt sie die Absicht ab.
navigate("/transactions");
requestAction("new-transaction");
},
},
{
key: "r",
description: "Neuer wiederkehrender Posten",
run: () => {
navigate("/recurrences");
requestAction("new-recurrence");
},
},
{ key: "/", description: "Suche", run: requestSearchFocus },
{ key: "?", description: "Übersicht der Kürzel", run: () => setHilfeOffen(true) },
],
[navigate, requestAction, requestSearchFocus],
);
useShortcuts(shortcuts, !hilfeOffen);
return (
<div className="flex h-full">
@@ -54,6 +88,16 @@ export function AppLayout() {
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="hidden sm:inline-flex"
onClick={() => setHilfeOffen(true)}
aria-label="Tastaturkürzel anzeigen"
>
<Keyboard aria-hidden className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
@@ -87,6 +131,8 @@ export function AppLayout() {
<Outlet />
</main>
</div>
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
/** Bindeglied zwischen den globalen Kürzeln und den einzelnen Seiten. */
import { type RefObject, useEffect } from "react";
import { type PendingAction, useUiStore } from "@/store/ui";
/**
* Ruft `run` auf, sobald das Layout die passende Absicht hinterlegt hat
* etwa nach `n` auf einer anderen Seite.
*/
export function usePendingAction(action: PendingAction, run: () => void): void {
const pending = useUiStore((zustand) => zustand.pendingAction);
const consume = useUiStore((zustand) => zustand.consumeAction);
useEffect(() => {
if (pending === action && consume(action)) run();
// `run` ändert sich bei jedem Rendern; die Absicht ist der Auslöser.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending, action]);
}
/** Fokussiert das Suchfeld der Seite, wenn `/` gedrückt wurde. */
export function useSearchFocus(ziel: RefObject<HTMLInputElement | null>): void {
const anfrage = useUiStore((zustand) => zustand.searchFocusRequest);
useEffect(() => {
if (anfrage === 0) return;
ziel.current?.focus();
ziel.current?.select();
}, [anfrage, ziel]);
}
+99
View File
@@ -0,0 +1,99 @@
/** Tests der Tastaturkürzel. */
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { useShortcuts, type Shortcut } from "@/hooks/useShortcuts";
function Huelle({ shortcuts, enabled = true }: { shortcuts: Shortcut[]; enabled?: boolean }) {
useShortcuts(shortcuts, enabled);
return (
<div>
<input aria-label="Eingabefeld" />
<textarea aria-label="Mehrzeilig" />
<button type="button">Knopf</button>
</div>
);
}
describe("useShortcuts", () => {
it("löst bei der passenden Taste aus", async () => {
const nutzer = userEvent.setup();
const neueBuchung = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: neueBuchung }]} />);
await nutzer.keyboard("n");
expect(neueBuchung).toHaveBeenCalledOnce();
});
it("greift nicht, während ein Eingabefeld den Fokus hat", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Eingabefeld"));
await nutzer.keyboard("nnn");
expect(ausloeser).not.toHaveBeenCalled();
expect(screen.getByLabelText("Eingabefeld")).toHaveValue("nnn");
});
it("greift auch in einem mehrzeiligen Feld nicht", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "r", description: "Neu", run: ausloeser }]} />);
await nutzer.click(screen.getByLabelText("Mehrzeilig"));
await nutzer.keyboard("r");
expect(ausloeser).not.toHaveBeenCalled();
});
it("ignoriert Tastenkombinationen mit Zusatztaste", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
// Strg+N ist das Browserkürzel für ein neues Fenster.
await nutzer.keyboard("{Control>}n{/Control}");
expect(ausloeser).not.toHaveBeenCalled();
});
it("versteht auch Sonderzeichen wie den Schrägstrich", async () => {
const nutzer = userEvent.setup();
const suche = vi.fn();
render(<Huelle shortcuts={[{ key: "/", description: "Suche", run: suche }]} />);
await nutzer.keyboard("/");
expect(suche).toHaveBeenCalledOnce();
});
it("lässt sich abschalten", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
render(
<Huelle enabled={false} shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
it("räumt den Zuhörer beim Abbau wieder ab", async () => {
const nutzer = userEvent.setup();
const ausloeser = vi.fn();
const { unmount } = render(
<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
);
unmount();
await nutzer.keyboard("n");
expect(ausloeser).not.toHaveBeenCalled();
});
});
+51
View File
@@ -0,0 +1,51 @@
/**
* Tastaturkürzel der Anwendung.
*
* Kürzel greifen nur, wenn gerade kein Eingabefeld den Fokus hat und keine
* Zusatztaste gedrückt ist sonst käme man beim Tippen nicht weit.
*/
import { useEffect } from "react";
export interface Shortcut {
/** Taste in Kleinschreibung, etwa "n" oder "/". */
key: string;
description: string;
run: () => void;
}
function schreibtGerade(ziel: EventTarget | null): boolean {
if (!(ziel instanceof HTMLElement)) return false;
if (ziel.isContentEditable) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(ziel.tagName);
}
export function useShortcuts(shortcuts: Shortcut[], enabled = true): void {
useEffect(() => {
if (!enabled || shortcuts.length === 0) return;
function behandeln(ereignis: KeyboardEvent) {
if (ereignis.metaKey || ereignis.ctrlKey || ereignis.altKey) return;
if (schreibtGerade(ereignis.target)) return;
const treffer = shortcuts.find(
(kuerzel) => kuerzel.key === ereignis.key.toLowerCase(),
);
if (!treffer) return;
ereignis.preventDefault();
treffer.run();
}
window.addEventListener("keydown", behandeln);
return () => window.removeEventListener("keydown", behandeln);
}, [shortcuts, enabled]);
}
/** Beschreibungen für die Übersicht der Kürzel. */
export const GLOBAL_SHORTCUTS: { key: string; description: string }[] = [
{ key: "n", description: "Neue Buchung" },
{ key: "r", description: "Neuer wiederkehrender Posten" },
{ key: "/", description: "Suche" },
{ key: "?", description: "Diese Übersicht" },
];
+11 -6
View File
@@ -3,16 +3,20 @@
@tailwind utilities;
@layer base {
/* Heller Modus */
/* Heller Modus.
Die Werte sind gegen die WCAG-Kontraste geprüft: Text erreicht überall
mindestens 4,5:1, Bedienelemente 3:1. Insbesondere `faint` und `accent`
sind bewusst dunkler als die naheliegenden Tailwind-Stufen slate-400
käme auf 2,56:1 und Weiß auf green-600 nur auf 3,30:1. */
:root {
--color-ground: 248 250 252;
--color-surface: 255 255 255;
--color-raised: 241 245 249;
--color-line: 226 232 240;
--color-line: 203 213 225;
--color-ink: 15 23 42;
--color-muted: 71 85 105;
--color-faint: 148 163 184;
--color-accent: 22 163 74;
--color-faint: 91 107 128;
--color-accent: 21 128 61;
--color-accent-ink: 255 255 255;
--color-positive: 21 128 61;
--color-negative: 190 18 60;
@@ -42,10 +46,11 @@
--color-ground: 15 17 21;
--color-surface: 24 27 33;
--color-raised: 33 37 45;
--color-line: 51 57 68;
--color-line: 59 66 80;
--color-ink: 226 232 240;
--color-muted: 148 163 184;
--color-faint: 100 116 139;
/* Heller als slate-500: dort läge der Hilfstext bei 3,62:1. */
--color-faint: 129 142 163;
--color-accent: 34 197 94;
--color-accent-ink: 4 20 11;
--color-positive: 74 222 128;
+4 -1
View File
@@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom";
import { QueryClientProvider } from "@tanstack/react-query";
import { App } from "@/App";
import { ErrorBoundary } from "@/components/ErrorBoundary";
import { Toaster } from "@/components/ui/Toaster";
import "@/index.css";
import { queryClient } from "@/lib/queryClient";
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
<Toaster />
</BrowserRouter>
</QueryClientProvider>
+6 -1
View File
@@ -1,6 +1,6 @@
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
import { type FormEvent, useMemo, useState } from "react";
import { type FormEvent, useMemo, useRef, useState } from "react";
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
@@ -17,6 +17,7 @@ import {
useRecurrences,
useSaveMerchant,
} from "@/hooks/useEntities";
import { useSearchFocus } from "@/hooks/usePageShortcuts";
import { formatMoney, toNumber } from "@/lib/format";
import { annualCostOf } from "@/lib/recurrenceMath";
import { useThemeStore } from "@/store/theme";
@@ -32,6 +33,9 @@ export function MerchantsPage() {
const { data: posten = [] } = useRecurrences({ active: true });
const entfernen = useDeleteMerchant();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
const kennzahlen = useMemo(() => {
const werte = new Map<number, { jahr: number; anzahl: number }>();
@@ -62,6 +66,7 @@ export function MerchantsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Firma suchen"
+27
View File
@@ -0,0 +1,27 @@
/** Seite für unbekannte Adressen. */
import { Link } from "react-router-dom";
import { Compass } from "lucide-react";
import { Button } from "@/components/ui/Button";
export function NotFoundPage() {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="text-center">
<Compass aria-hidden className="mx-auto h-10 w-10 text-faint" />
<p className="mt-3 text-sm font-medium text-muted">Seite nicht gefunden</p>
<h1 className="mt-1 text-xl font-semibold text-ink">
Diese Adresse gibt es in moneyfy nicht.
</h1>
<p className="mx-auto mt-2 max-w-sm text-sm text-muted">
Vielleicht hat sich ein Tippfehler eingeschlichen oder der Link ist veraltet.
</p>
<Link to="/" className="mt-4 inline-block">
<Button variant="primary">Zum Dashboard</Button>
</Link>
</div>
</div>
);
}
+7 -1
View File
@@ -1,10 +1,11 @@
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
import { useState } from "react";
import { useRef, useState } from "react";
import { Plus, Repeat, Search } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
const { data: firmenSeite } = useMerchants();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-recurrence", () => setFormularFuer(null));
const firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
const begriff = suche.trim().toLowerCase();
const sichtbar = begriff
@@ -52,6 +57,7 @@ export function RecurrencesPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Posten suchen"
+10 -3
View File
@@ -26,7 +26,7 @@ import type { Subscription } from "@/types/api";
type Reiter = "subscriptions" | "categories" | "year" | "export";
const REITER: { id: Reiter; label: string }[] = [
{ id: "subscriptions", label: "Abos" },
{ id: "subscriptions", label: "Laufende Kosten" },
{ id: "categories", label: "Kategorien" },
{ id: "year", label: "Jahresvergleich" },
{ id: "export", label: "Export" },
@@ -104,7 +104,12 @@ function SubscriptionsTab() {
return (
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<StatTile label="Abos gesamt p. a." value={formatMoney(data.total_annual)} large />
<StatTile
label="Laufende Kosten p. a."
value={formatMoney(data.total_annual)}
large
hint="Alle wiederkehrenden Ausgaben ohne Ratenzahlungen."
/>
<StatTile label="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
<StatTile
label="Laufende Posten"
@@ -135,7 +140,9 @@ function SubscriptionsTab() {
<ChartCard
title="Alle laufenden Posten"
description="Ratenzahlungen sind gekennzeichnet und zählen nicht in die Gesamtsumme."
description="Jede wiederkehrende Ausgabe mit ihren Jahreskosten von der Miete bis
zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht in die
Gesamtsumme, weil sie enden."
actions={
<Select
aria-label="Sortierung"
+7 -1
View File
@@ -1,11 +1,12 @@
/** Einmalige Buchungen: Liste, Filter und Formular. */
import { type FormEvent, useState } from "react";
import { type FormEvent, useRef, useState } from "react";
import { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
import { MerchantLogo } from "@/components/MerchantLogo";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
@@ -35,6 +36,10 @@ export function TransactionsPage() {
const entfernen = useDeleteTransaction();
const kategorieName = useCategoryLookup();
const suchfeld = useRef<HTMLInputElement>(null);
useSearchFocus(suchfeld);
usePendingAction("new-transaction", () => setBearbeiten(null));
const buchungen = data?.items ?? [];
const gesamt = data?.total ?? 0;
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
@@ -53,6 +58,7 @@ export function TransactionsPage() {
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
/>
<Input
ref={suchfeld}
value={suche}
onChange={(ereignis) => {
setSuche(ereignis.target.value);
+38
View File
@@ -0,0 +1,38 @@
/**
* Kleiner Zustand für seitenübergreifende Absichten.
*
* Ein Tastaturkürzel wird im Layout ausgelöst, wirkt aber auf einer anderen
* Seite. Statt Rückrufe durch den Baum zu reichen, hinterlegt das Layout hier
* eine Absicht, die die Zielseite nach dem Wechsel abholt.
*/
import { create } from "zustand";
export type PendingAction = "new-transaction" | "new-recurrence";
interface UiStore {
pendingAction: PendingAction | null;
/** Zähler; jede Erhöhung fordert die aktive Seite auf, ihr Suchfeld zu fokussieren. */
searchFocusRequest: number;
requestAction: (action: PendingAction) => void;
/** Holt die Absicht ab und löscht sie. True, wenn sie zur Seite passte. */
consumeAction: (action: PendingAction) => boolean;
requestSearchFocus: () => void;
}
export const useUiStore = create<UiStore>((set, get) => ({
pendingAction: null,
searchFocusRequest: 0,
requestAction: (action) => set({ pendingAction: action }),
consumeAction: (action) => {
if (get().pendingAction !== action) return false;
set({ pendingAction: null });
return true;
},
requestSearchFocus: () =>
set((zustand) => ({ searchFocusRequest: zustand.searchFocusRequest + 1 })),
}));