feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
- 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:
co-authored by
Claude Opus 5
parent
bdf4097bd7
commit
d63cc0af65
@@ -7,11 +7,20 @@ Zwei Workflows:
|
||||
| `ci.yml` | jeder Push, jeder Pull Request | Backend: `ruff check`, `ruff format --check`, `pytest` gegen einen PostgreSQL-Dienst. Frontend: `npm ci`, `tsc --noEmit`, `eslint`, `vitest`, Bundle-Bau. |
|
||||
| `build.yml` | Push auf `main`, Tags `v*` | Baut beide Images für `linux/amd64` und lädt sie in die Gitea-Registry. **Kein Deploy** – das Ausrollen erfolgt von Hand. |
|
||||
|
||||
## Benötigte Repo-Secrets
|
||||
## Anmeldung an der Registry
|
||||
|
||||
`build.yml` versucht die Anmeldung in dieser Reihenfolge:
|
||||
|
||||
1. das Repo-Secret **`REGISTRY_TOKEN`**, falls vorhanden,
|
||||
2. sonst `GITEA_TOKEN` – den Token, den Gitea jedem Lauf automatisch mitgibt.
|
||||
|
||||
Damit läuft der Workflow im Normalfall ohne jede manuelle Einrichtung. Reicht
|
||||
das Recht des automatischen Tokens auf der Instanz nicht aus, bricht der Schritt
|
||||
mit `denied` oder `unauthorized` ab; dann das Secret von Hand anlegen:
|
||||
|
||||
| Name | Zweck | Woher |
|
||||
|---|---|---|
|
||||
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token** mit dem Recht `write:package` |
|
||||
| `REGISTRY_TOKEN` | Anmeldung an `git.menzel.center` zum Hochladen der Images | Gitea → Benutzereinstellungen → Applications → **Generate New Token**, bei *package* auf `Read and Write` |
|
||||
|
||||
Anlegen unter *Repository → Settings → Actions → Secrets → Add Secret*.
|
||||
|
||||
@@ -19,6 +28,9 @@ Als Benutzername verwendet der Workflow `${{ github.actor }}`, also den Auslöse
|
||||
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
|
||||
`menzeljonas`.
|
||||
|
||||
> Fehlt beides, meldet der Schritt `::error::Password required` und es entsteht
|
||||
> **kein Image** – `docker compose pull` läuft dann in ein `not found`.
|
||||
|
||||
## Erzeugte Tags
|
||||
|
||||
| Auslöser | Tags |
|
||||
|
||||
@@ -38,7 +38,10 @@ jobs:
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
# Bevorzugt das eigens angelegte Secret. Fehlt es, greift der Token,
|
||||
# den Gitea jedem Lauf ohnehin mitgibt – damit läuft der Workflow ohne
|
||||
# jede manuelle Einrichtung.
|
||||
password: ${{ secrets.REGISTRY_TOKEN || secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Tags und Beschriftungen ermitteln
|
||||
id: meta
|
||||
@@ -74,6 +77,16 @@ jobs:
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
||||
provenance: false
|
||||
|
||||
- name: Hinweis bei fehlgeschlagener Anmeldung
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Die Anmeldung an ${{ env.REGISTRY }} ist fehlgeschlagen." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Lege ein Repo-Secret **REGISTRY_TOKEN** an:" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "1. Benutzereinstellungen → Applications → Generate New Token" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "2. Bei *package* auf \`Read and Write\` stellen" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "3. Repository → Settings → Actions → Secrets → Add Secret" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Ergebnis zusammenfassen
|
||||
run: |
|
||||
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
+24
-1
@@ -7,7 +7,30 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Noch keine Änderungen seit 0.1.0.
|
||||
### Hinzugefügt
|
||||
|
||||
- Demo-Seed mit einem realistischen deutschen Haushalt: drei Konten, zehn Firmen
|
||||
samt Logos, zwölf wiederkehrende Posten (Miete, Strom, Netflix, Spotify,
|
||||
jährliche Kfz-Versicherung, Handyvertrag mit Mindestlaufzeit, Kredit über 36
|
||||
Raten, Gehalt), bestätigte Historie, Buchungen, Budgets und Sparziele. Alle
|
||||
Termine liegen relativ zum heutigen Monat. Aufruf über `make seed-demo`.
|
||||
- Tastaturkürzel: `n` neue Buchung, `r` neuer Posten, `/` Suche, `?` Übersicht.
|
||||
Sie greifen nur, solange kein Eingabefeld den Fokus hat.
|
||||
- Fehlergrenze für unerwartete Renderfehler und eine eigene 404-Seite.
|
||||
|
||||
### Geändert
|
||||
|
||||
- Farbtoken beider Modi gegen die WCAG-Kontraste geprüft und nachgezogen: der
|
||||
Hilfstext lag im hellen Modus bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1.
|
||||
Jetzt erreicht jeder Text mindestens 4,5:1 und jedes Bedienelement 3:1.
|
||||
- Die Abo-Übersicht heißt „Laufende Kosten" – sie enthält jede wiederkehrende
|
||||
Ausgabe, auch Miete und Sparplan, und nicht nur Abonnements.
|
||||
|
||||
### Behoben
|
||||
|
||||
- Die Testsuite hing an einer nicht eingecheckten lokalen `.env`: ohne sie galt
|
||||
`COOKIE_SECURE=true`, httpx verwarf die Secure-Cookies über http, und 106
|
||||
Tests scheiterten mit 401. Aufgefallen ist das erst in der CI.
|
||||
|
||||
## [0.1.0] – 2026-09-09
|
||||
|
||||
|
||||
@@ -32,6 +32,14 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen
|
||||
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
|
||||
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
|
||||
|
||||
.PHONY: seed-demo
|
||||
seed-demo: ## Realistische Demodaten anlegen (vorhandene Daten bleiben, sonst --reset)
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo
|
||||
|
||||
.PHONY: seed-demo-reset
|
||||
seed-demo-reset: ## Demodaten neu aufsetzen; löscht Konten, Firmen, Posten und Buchungen
|
||||
cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo --reset
|
||||
|
||||
.PHONY: dev
|
||||
dev: ## Backend mit Hot Reload starten
|
||||
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
||||
|
||||
@@ -4,15 +4,18 @@ Selbst gehostete Web-Anwendung zur Planung monatlicher Kosten und Einkünfte.
|
||||
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
|
||||
hinter einem Reverse Proxy.
|
||||
|
||||
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
> **Version 0.1.0.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Screenshots
|
||||
|
||||
_Platzhalter – werden ergänzt, sobald die Oberfläche steht._
|
||||
Die Bilder entstehen aus dem Demo-Datensatz (`make seed-demo`), damit sie ohne
|
||||
echte Kontostände auskommen. Ablegen unter `docs/screenshots/`.
|
||||
|
||||
| Dashboard | Cashflow-Kalender | Firmen |
|
||||
|---|---|---|
|
||||
| _folgt_ | _folgt_ | _folgt_ |
|
||||
| | |
|
||||
|---|---|
|
||||
| <br>**Dashboard** – verfügbar nach Fixkosten, Kategorienring, Zwölf-Monats-Vorschau | <br>**Cashflow-Kalender** – Fälligkeiten je Tag mit Firmenlogo, darunter der Kontostandsverlauf |
|
||||
| <br>**Wiederkehrend** – Abos, Verträge und Raten mit Klartext-Rhythmus | <br>**RRULE-Editor** – geführte Auswahl, Expertenmodus und Live-Vorschau der nächsten Termine |
|
||||
| <br>**Firmen** – Kachelgrid mit Logo, Markenfarbe und Jahreskosten | <br>**Auswertungen** – laufende Kosten, Jahresvergleich und Export |
|
||||
|
||||
## Funktionsumfang
|
||||
|
||||
@@ -83,9 +86,45 @@ make fe-install
|
||||
make fe-dev # http://localhost:5173
|
||||
```
|
||||
|
||||
Für einen gefüllten Stand:
|
||||
|
||||
```bash
|
||||
make seed-demo # realistischer Beispielhaushalt, Termine relativ zu heute
|
||||
make seed-demo-reset # setzt Konten, Firmen, Posten und Buchungen vorher zurück
|
||||
```
|
||||
|
||||
Der Demo-Seed legt drei Konten an, zehn Firmen samt Logos, zwölf wiederkehrende
|
||||
Posten (Miete, Strom, Netflix, Spotify, jährliche Kfz-Versicherung, Handyvertrag
|
||||
mit Mindestlaufzeit, Kredit über 36 Raten, Gehalt), bestätigt die vergangenen
|
||||
Fälligkeiten, ergänzt Buchungen, Budgets und Sparziele.
|
||||
|
||||
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
|
||||
|
||||
## Bedienung
|
||||
|
||||
| Kürzel | Wirkung |
|
||||
|---|---|
|
||||
| `n` | Neue Buchung |
|
||||
| `r` | Neuer wiederkehrender Posten |
|
||||
| `/` | Suchfeld der aktuellen Seite |
|
||||
| `?` | Übersicht der Kürzel |
|
||||
| `Esc` | Dialog schließen |
|
||||
|
||||
Kürzel greifen nur, solange kein Eingabefeld den Fokus hat.
|
||||
|
||||
## Barrierefreiheit
|
||||
|
||||
Die Farbtoken sind gegen die WCAG-Kontraste geprüft – in beiden Modi erreicht
|
||||
jeder Text mindestens 4,5:1, jedes Bedienelement 3:1. Zwei Werte weichen
|
||||
deshalb bewusst von den naheliegenden Tailwind-Stufen ab: `slate-400` als
|
||||
Hilfstext käme auf hellem Grund nur auf 2,56:1, und Weiß auf `green-600` nur
|
||||
auf 3,30:1.
|
||||
|
||||
Die Serienfarben der Diagramme sind zusätzlich auf Farbfehlsichtigkeit geprüft
|
||||
(siehe [Diagramme](#diagramme)). Jeder Zustand – Budget-Ampel, Versandstatus,
|
||||
Vertragswarnung – trägt neben der Farbe ein Symbol und einen Wortlaut.
|
||||
|
||||
## Konfiguration
|
||||
|
||||
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
||||
|
||||
@@ -274,9 +274,10 @@ async def read_categories(
|
||||
@router.get(
|
||||
"/subscriptions",
|
||||
response_model=SubscriptionReportOut,
|
||||
summary="Abo-Übersicht",
|
||||
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
|
||||
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
|
||||
summary="Laufende Kosten",
|
||||
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten – von der "
|
||||
"Miete bis zum Streamingdienst. Ratenzahlungen sind gekennzeichnet und zählen nicht "
|
||||
"in `total_annual`, weil sie ein Ende haben.",
|
||||
)
|
||||
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||||
bericht = await subscriptions(session)
|
||||
|
||||
@@ -98,7 +98,7 @@ class SubscriptionOut(ApiModel):
|
||||
monthly_cost: Money
|
||||
rrule: str
|
||||
is_installment: bool = Field(
|
||||
description="Ratenzahlungen zählen nicht in die Summe „Abos gesamt p. a.“."
|
||||
description="Ratenzahlungen zählen nicht in `total_annual`, weil sie enden."
|
||||
)
|
||||
is_cancelled: bool
|
||||
contract_term: ContractTermOut | None = None
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Screenshots
|
||||
|
||||
Die Bilder in der [README](../../README.md) werden aus diesem Verzeichnis
|
||||
geladen. Erwartet werden:
|
||||
|
||||
| Datei | Ansicht |
|
||||
|---|---|
|
||||
| `dashboard.png` | `/` – Dashboard |
|
||||
| `calendar.png` | `/calendar` – Cashflow-Kalender |
|
||||
| `recurrences.png` | `/recurrences` – wiederkehrende Posten |
|
||||
| `rrule-editor.png` | Dialog „Neuer wiederkehrender Posten" |
|
||||
| `merchants.png` | `/merchants` – Firmen |
|
||||
| `reports.png` | `/reports` – Auswertungen |
|
||||
|
||||
## Aufnehmen
|
||||
|
||||
```bash
|
||||
make seed-demo-reset # reproduzierbarer Stand ohne echte Kontostände
|
||||
make dev # Backend
|
||||
make fe-dev # Frontend auf http://localhost:5173
|
||||
```
|
||||
|
||||
Im dunklen Modus aufnehmen – das ist die Vorgabe der Anwendung. Browserfenster
|
||||
auf 1440 × 900 stellen, damit die Seitenleiste dauerhaft sichtbar ist. Die Bilder
|
||||
zeigen ausschließlich Demodaten; vor dem Einchecken kurz prüfen, dass keine
|
||||
echten Beträge, Kontonummern oder Mailadressen darauf zu sehen sind.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 }}>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
<Toaster />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })),
|
||||
}));
|
||||
Reference in New Issue
Block a user