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. |
|
| `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. |
|
| `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 |
|
| 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*.
|
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
|
des Laufs. Dieser Benutzer braucht Schreibrecht auf die Pakete des Namensraums
|
||||||
`menzeljonas`.
|
`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
|
## Erzeugte Tags
|
||||||
|
|
||||||
| Auslöser | Tags |
|
| Auslöser | Tags |
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
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
|
- name: Tags und Beschriftungen ermitteln
|
||||||
id: meta
|
id: meta
|
||||||
@@ -74,6 +77,16 @@ jobs:
|
|||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.NAMESPACE }}/moneyfy-${{ matrix.component }}:buildcache,mode=max
|
||||||
provenance: false
|
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
|
- name: Ergebnis zusammenfassen
|
||||||
run: |
|
run: |
|
||||||
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
echo "### moneyfy-${{ matrix.component }}" >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|||||||
+24
-1
@@ -7,7 +7,30 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [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)
|
vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang)
|
||||||
cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py
|
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
|
.PHONY: dev
|
||||||
dev: ## Backend mit Hot Reload starten
|
dev: ## Backend mit Hot Reload starten
|
||||||
cd $(BACKEND) && .venv/bin/uvicorn app.main:app --reload --port 8000
|
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
|
Single-User, deutschsprachige Oberfläche, ausgelegt für den Betrieb im Homelab
|
||||||
hinter einem Reverse Proxy.
|
hinter einem Reverse Proxy.
|
||||||
|
|
||||||
> **Status:** in Entwicklung. Siehe [CHANGELOG.md](CHANGELOG.md).
|
> **Version 0.1.0.** Siehe [CHANGELOG.md](CHANGELOG.md).
|
||||||
|
|
||||||
## Screenshots
|
## 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
|
## Funktionsumfang
|
||||||
|
|
||||||
@@ -83,9 +86,45 @@ make fe-install
|
|||||||
make fe-dev # http://localhost:5173
|
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`,
|
Nützliche Ziele: `make check` (alle Prüfungen), `make test`, `make lint`,
|
||||||
`make format`, `make fe-lint`, `make fe-test`, `make fe-build`.
|
`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
|
## Konfiguration
|
||||||
|
|
||||||
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
Alle Einstellungen kommen aus Umgebungsvariablen; `.env.example` enthält die
|
||||||
|
|||||||
@@ -274,9 +274,10 @@ async def read_categories(
|
|||||||
@router.get(
|
@router.get(
|
||||||
"/subscriptions",
|
"/subscriptions",
|
||||||
response_model=SubscriptionReportOut,
|
response_model=SubscriptionReportOut,
|
||||||
summary="Abo-Übersicht",
|
summary="Laufende Kosten",
|
||||||
description="Alle laufenden Ausgabenposten mit Jahreskosten. Ratenzahlungen sind "
|
description="Jede aktive wiederkehrende Ausgabe mit ihren Jahreskosten – von der "
|
||||||
"gekennzeichnet und zählen nicht in die Gesamtsumme.",
|
"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:
|
async def read_subscriptions(session: DbSession) -> SubscriptionReportOut:
|
||||||
bericht = await subscriptions(session)
|
bericht = await subscriptions(session)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class SubscriptionOut(ApiModel):
|
|||||||
monthly_cost: Money
|
monthly_cost: Money
|
||||||
rrule: str
|
rrule: str
|
||||||
is_installment: bool = Field(
|
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
|
is_cancelled: bool
|
||||||
contract_term: ContractTermOut | None = None
|
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. */
|
/** 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 { AppLayout } from "@/components/layout/AppLayout";
|
||||||
import { Skeleton } from "@/components/ui/Feedback";
|
import { Skeleton } from "@/components/ui/Feedback";
|
||||||
@@ -11,6 +11,7 @@ import { ChangePasswordPage } from "@/pages/ChangePasswordPage";
|
|||||||
import { DashboardPage } from "@/pages/DashboardPage";
|
import { DashboardPage } from "@/pages/DashboardPage";
|
||||||
import { GoalsPage } from "@/pages/GoalsPage";
|
import { GoalsPage } from "@/pages/GoalsPage";
|
||||||
import { LoginPage } from "@/pages/LoginPage";
|
import { LoginPage } from "@/pages/LoginPage";
|
||||||
|
import { NotFoundPage } from "@/pages/NotFoundPage";
|
||||||
import { ReportsPage } from "@/pages/ReportsPage";
|
import { ReportsPage } from "@/pages/ReportsPage";
|
||||||
import { MerchantsPage } from "@/pages/MerchantsPage";
|
import { MerchantsPage } from "@/pages/MerchantsPage";
|
||||||
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
import { RecurrencesPage } from "@/pages/RecurrencesPage";
|
||||||
@@ -45,7 +46,7 @@ export function App() {
|
|||||||
<Route path="/goals" element={<GoalsPage />} />
|
<Route path="/goals" element={<GoalsPage />} />
|
||||||
<Route path="/reports" element={<ReportsPage />} />
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</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. */
|
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Outlet } from "react-router-dom";
|
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 { Sidebar } from "@/components/layout/Sidebar";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
import { useLogout, useMe } from "@/hooks/useAuth";
|
import { useLogout, useMe } from "@/hooks/useAuth";
|
||||||
|
import { type Shortcut, useShortcuts } from "@/hooks/useShortcuts";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
|
import { useUiStore } from "@/store/ui";
|
||||||
|
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const [menuOffen, setMenuOffen] = useState(false);
|
const [menuOffen, setMenuOffen] = useState(false);
|
||||||
|
const [hilfeOffen, setHilfeOffen] = useState(false);
|
||||||
const { data: benutzer } = useMe();
|
const { data: benutzer } = useMe();
|
||||||
const abmelden = useLogout();
|
const abmelden = useLogout();
|
||||||
const theme = useThemeStore((zustand) => zustand.theme);
|
const theme = useThemeStore((zustand) => zustand.theme);
|
||||||
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
|
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 (
|
return (
|
||||||
<div className="flex h-full">
|
<div className="flex h-full">
|
||||||
@@ -54,6 +88,16 @@ export function AppLayout() {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -87,6 +131,8 @@ export function AppLayout() {
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ShortcutHelp open={hilfeOffen} onClose={() => setHilfeOffen(false)} />
|
||||||
</div>
|
</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;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@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 {
|
:root {
|
||||||
--color-ground: 248 250 252;
|
--color-ground: 248 250 252;
|
||||||
--color-surface: 255 255 255;
|
--color-surface: 255 255 255;
|
||||||
--color-raised: 241 245 249;
|
--color-raised: 241 245 249;
|
||||||
--color-line: 226 232 240;
|
--color-line: 203 213 225;
|
||||||
--color-ink: 15 23 42;
|
--color-ink: 15 23 42;
|
||||||
--color-muted: 71 85 105;
|
--color-muted: 71 85 105;
|
||||||
--color-faint: 148 163 184;
|
--color-faint: 91 107 128;
|
||||||
--color-accent: 22 163 74;
|
--color-accent: 21 128 61;
|
||||||
--color-accent-ink: 255 255 255;
|
--color-accent-ink: 255 255 255;
|
||||||
--color-positive: 21 128 61;
|
--color-positive: 21 128 61;
|
||||||
--color-negative: 190 18 60;
|
--color-negative: 190 18 60;
|
||||||
@@ -42,10 +46,11 @@
|
|||||||
--color-ground: 15 17 21;
|
--color-ground: 15 17 21;
|
||||||
--color-surface: 24 27 33;
|
--color-surface: 24 27 33;
|
||||||
--color-raised: 33 37 45;
|
--color-raised: 33 37 45;
|
||||||
--color-line: 51 57 68;
|
--color-line: 59 66 80;
|
||||||
--color-ink: 226 232 240;
|
--color-ink: 226 232 240;
|
||||||
--color-muted: 148 163 184;
|
--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: 34 197 94;
|
||||||
--color-accent-ink: 4 20 11;
|
--color-accent-ink: 4 20 11;
|
||||||
--color-positive: 74 222 128;
|
--color-positive: 74 222 128;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { BrowserRouter } from "react-router-dom";
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { App } from "@/App";
|
import { App } from "@/App";
|
||||||
|
import { ErrorBoundary } from "@/components/ErrorBoundary";
|
||||||
import { Toaster } from "@/components/ui/Toaster";
|
import { Toaster } from "@/components/ui/Toaster";
|
||||||
import "@/index.css";
|
import "@/index.css";
|
||||||
import { queryClient } from "@/lib/queryClient";
|
import { queryClient } from "@/lib/queryClient";
|
||||||
@@ -16,7 +17,9 @@ createRoot(wurzel).render(
|
|||||||
<StrictMode>
|
<StrictMode>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/** Firmen als Kachelgrid mit Logo, Markenfarbe und Jahreskosten. */
|
/** 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";
|
import { Building2, ImageIcon, Pencil, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
useRecurrences,
|
useRecurrences,
|
||||||
useSaveMerchant,
|
useSaveMerchant,
|
||||||
} from "@/hooks/useEntities";
|
} from "@/hooks/useEntities";
|
||||||
|
import { useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { formatMoney, toNumber } from "@/lib/format";
|
import { formatMoney, toNumber } from "@/lib/format";
|
||||||
import { annualCostOf } from "@/lib/recurrenceMath";
|
import { annualCostOf } from "@/lib/recurrenceMath";
|
||||||
import { useThemeStore } from "@/store/theme";
|
import { useThemeStore } from "@/store/theme";
|
||||||
@@ -32,6 +33,9 @@ export function MerchantsPage() {
|
|||||||
const { data: posten = [] } = useRecurrences({ active: true });
|
const { data: posten = [] } = useRecurrences({ active: true });
|
||||||
const entfernen = useDeleteMerchant();
|
const entfernen = useDeleteMerchant();
|
||||||
|
|
||||||
|
const suchfeld = useRef<HTMLInputElement>(null);
|
||||||
|
useSearchFocus(suchfeld);
|
||||||
|
|
||||||
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
|
// Jahreskosten und Vertragszahl je Firma aus den aktiven Posten.
|
||||||
const kennzahlen = useMemo(() => {
|
const kennzahlen = useMemo(() => {
|
||||||
const werte = new Map<number, { jahr: number; anzahl: number }>();
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||||
placeholder="Firma suchen"
|
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. */
|
/** Wiederkehrende Posten: Liste, Formular und Detailansicht. */
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
|
||||||
import { Plus, Repeat, Search } from "lucide-react";
|
import { Plus, Repeat, Search } from "lucide-react";
|
||||||
|
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
|
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||||
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
|
import { RecurrenceDetailDrawer } from "@/components/RecurrenceDetail";
|
||||||
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
|
import { RecurrenceFormDialog } from "@/components/RecurrenceForm";
|
||||||
@@ -28,6 +29,10 @@ export function RecurrencesPage() {
|
|||||||
const { data: firmenSeite } = useMerchants();
|
const { data: firmenSeite } = useMerchants();
|
||||||
const kategorieName = useCategoryLookup();
|
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 firmen = new Map((firmenSeite?.items ?? []).map((firma) => [firma.id, firma]));
|
||||||
const begriff = suche.trim().toLowerCase();
|
const begriff = suche.trim().toLowerCase();
|
||||||
const sichtbar = begriff
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
onChange={(ereignis) => setSuche(ereignis.target.value)}
|
||||||
placeholder="Posten suchen"
|
placeholder="Posten suchen"
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import type { Subscription } from "@/types/api";
|
|||||||
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
type Reiter = "subscriptions" | "categories" | "year" | "export";
|
||||||
|
|
||||||
const REITER: { id: Reiter; label: string }[] = [
|
const REITER: { id: Reiter; label: string }[] = [
|
||||||
{ id: "subscriptions", label: "Abos" },
|
{ id: "subscriptions", label: "Laufende Kosten" },
|
||||||
{ id: "categories", label: "Kategorien" },
|
{ id: "categories", label: "Kategorien" },
|
||||||
{ id: "year", label: "Jahresvergleich" },
|
{ id: "year", label: "Jahresvergleich" },
|
||||||
{ id: "export", label: "Export" },
|
{ id: "export", label: "Export" },
|
||||||
@@ -104,7 +104,12 @@ function SubscriptionsTab() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid gap-3 sm:grid-cols-3">
|
<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="Entspricht pro Monat" value={formatMoney(data.total_monthly)} />
|
||||||
<StatTile
|
<StatTile
|
||||||
label="Laufende Posten"
|
label="Laufende Posten"
|
||||||
@@ -135,7 +140,9 @@ function SubscriptionsTab() {
|
|||||||
|
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title="Alle laufenden Posten"
|
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={
|
actions={
|
||||||
<Select
|
<Select
|
||||||
aria-label="Sortierung"
|
aria-label="Sortierung"
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
/** Einmalige Buchungen: Liste, Filter und Formular. */
|
/** 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 { Pencil, Plus, Receipt, Search, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
|
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
|
||||||
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
|
||||||
|
import { usePendingAction, useSearchFocus } from "@/hooks/usePageShortcuts";
|
||||||
import { MerchantLogo } from "@/components/MerchantLogo";
|
import { MerchantLogo } from "@/components/MerchantLogo";
|
||||||
import { PageHeader } from "@/components/layout/AppLayout";
|
import { PageHeader } from "@/components/layout/AppLayout";
|
||||||
import { Button } from "@/components/ui/Button";
|
import { Button } from "@/components/ui/Button";
|
||||||
@@ -35,6 +36,10 @@ export function TransactionsPage() {
|
|||||||
const entfernen = useDeleteTransaction();
|
const entfernen = useDeleteTransaction();
|
||||||
const kategorieName = useCategoryLookup();
|
const kategorieName = useCategoryLookup();
|
||||||
|
|
||||||
|
const suchfeld = useRef<HTMLInputElement>(null);
|
||||||
|
useSearchFocus(suchfeld);
|
||||||
|
usePendingAction("new-transaction", () => setBearbeiten(null));
|
||||||
|
|
||||||
const buchungen = data?.items ?? [];
|
const buchungen = data?.items ?? [];
|
||||||
const gesamt = data?.total ?? 0;
|
const gesamt = data?.total ?? 0;
|
||||||
const seite = Math.floor((filter.offset ?? 0) / SEITENGROESSE) + 1;
|
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"
|
className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-faint"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
|
ref={suchfeld}
|
||||||
value={suche}
|
value={suche}
|
||||||
onChange={(ereignis) => {
|
onChange={(ereignis) => {
|
||||||
setSuche(ereignis.target.value);
|
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