feat(frontend): Oberfläche mit RRULE-Editor und Logo-Auswahl

- Vite, React 18, TypeScript und Tailwind mit dunklem Standard-Theme über
  CSS-Variablen, heller Modus umschaltbar und in localStorage gemerkt
- Anmeldung, erzwungener Passwortwechsel, Layout mit Seitenleiste
- API-Client mit stiller Token-Erneuerung, TanStack Query mit Fehler-Toasts
- Seiten Recurrences (inkl. Detail-Drawer), Transactions, Merchants, Settings
- Geführter RRULE-Editor mit Vorlagen, Expertenmodus, deutschem Klartext und
  Live-Vorschau der nächsten Termine vom preview-Endpunkt
- Firmen-Kachelgrid mit Logo, Markenfarbe und Logo-Auswahldialog samt Upload
- Beträge durchgängig in de-DE, Eingabe in beiden Schreibweisen
- 49 Tests, tsc --noEmit und eslint sauber, Produktionsbundle baut

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
moneyfy
2026-09-09 16:23:04 +02:00
co-authored by Claude Opus 5
parent 8edb69fe6a
commit 8d6fcfeb58
57 changed files with 13064 additions and 2 deletions
+107
View File
@@ -0,0 +1,107 @@
/** Auswahlfelder für Konten, Kategorien und Firmen. */
import { Select } from "@/components/ui/Field";
import { useAccounts, useCategoryTree, useMerchants } from "@/hooks/useEntities";
import type { EntryKind } from "@/types/api";
export function AccountSelect({
id,
value,
onChange,
required,
}: {
id?: string;
value: number | null;
onChange: (value: number) => void;
required?: boolean;
}) {
const { data: konten = [] } = useAccounts(true);
return (
<Select
id={id}
required={required}
value={value ?? ""}
onChange={(ereignis) => onChange(Number(ereignis.target.value))}
>
<option value="" disabled>
Konto wählen
</option>
{konten.map((konto) => (
<option key={konto.id} value={konto.id}>
{konto.name}
</option>
))}
</Select>
);
}
export function CategorySelect({
id,
value,
kind,
onChange,
required,
}: {
id?: string;
value: number | null;
kind: EntryKind;
onChange: (value: number) => void;
required?: boolean;
}) {
const { data: baum = [] } = useCategoryTree();
const passend = baum.filter((oberkategorie) => oberkategorie.kind === kind);
return (
<Select
id={id}
required={required}
value={value ?? ""}
onChange={(ereignis) => onChange(Number(ereignis.target.value))}
>
<option value="" disabled>
Kategorie wählen
</option>
{passend.map((oberkategorie) => (
<optgroup key={oberkategorie.id} label={oberkategorie.name}>
<option value={oberkategorie.id}>{oberkategorie.name} (allgemein)</option>
{oberkategorie.children.map((unterkategorie) => (
<option key={unterkategorie.id} value={unterkategorie.id}>
{unterkategorie.name}
</option>
))}
</optgroup>
))}
</Select>
);
}
export function MerchantSelect({
id,
value,
onChange,
}: {
id?: string;
value: number | null;
onChange: (value: number | null) => void;
}) {
const { data } = useMerchants();
const firmen = data?.items ?? [];
return (
<Select
id={id}
value={value ?? ""}
onChange={(ereignis) =>
onChange(ereignis.target.value ? Number(ereignis.target.value) : null)
}
>
<option value="">Keine Firma</option>
{firmen.map((firma) => (
<option key={firma.id} value={firma.id}>
{firma.name}
</option>
))}
</Select>
);
}
+51
View File
@@ -0,0 +1,51 @@
/** Logo einer Firma; ohne hinterlegtes Bild erscheinen die Initialen. */
import { useThemeStore } from "@/store/theme";
import { cn } from "@/lib/cn";
import { initialsOf } from "@/lib/initials";
import { logoUrl } from "@/lib/api";
import type { Merchant } from "@/types/api";
export interface MerchantLogoProps {
merchant: Pick<Merchant, "name" | "logo_asset_id" | "brand_color" | "brand_color_dark">;
size?: number;
className?: string;
}
export function MerchantLogo({ merchant, size = 40, className }: MerchantLogoProps) {
const theme = useThemeStore((zustand) => zustand.theme);
// Auf dunklem Grund die aufgehellte Variante nehmen sie erfüllt den Kontrast.
const farbe =
(theme === "dark" ? merchant.brand_color_dark : merchant.brand_color) ??
merchant.brand_color ??
"rgb(var(--color-faint))";
return (
<div
className={cn(
"flex shrink-0 items-center justify-center overflow-hidden rounded-lg border border-line bg-raised",
className,
)}
style={{ width: size, height: size }}
>
{merchant.logo_asset_id ? (
<img
src={logoUrl(merchant.logo_asset_id)}
alt=""
width={size}
height={size}
loading="lazy"
className="h-full w-full object-contain p-1.5"
/>
) : (
<span
aria-hidden
className="font-semibold"
style={{ color: farbe, fontSize: Math.round(size * 0.36) }}
>
{initialsOf(merchant.name)}
</span>
)}
</div>
);
}
@@ -0,0 +1,172 @@
/** Tests des Logo-Auswahldialogs. */
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MerchantLogoDialog } from "@/components/MerchantLogoDialog";
import { renderWithProviders } from "@/test/utils";
import type { LogoCandidate, Merchant } from "@/types/api";
const FIRMA: Merchant = {
id: 3,
name: "Netflix",
normalized_name: "netflix",
domain: "netflix.com",
aliases: [],
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
logo_source: null,
logo_status: "pending",
created_at: "2026-03-01T10:00:00+01:00",
};
const KANDIDATEN: LogoCandidate[] = [
{
candidate_id: 11,
source: "simple-icons",
title: "Netflix",
score: 1,
mime: "image/svg+xml",
width: null,
height: null,
brand_color: "#e50914",
is_preselected: true,
},
{
candidate_id: 12,
source: "favicon",
title: "Netflix",
score: 0.5,
mime: "image/png",
width: 128,
height: 128,
brand_color: "#e40813",
is_preselected: false,
},
{
candidate_id: 13,
source: "generated",
title: "Netflix",
score: 0.1,
mime: "image/svg+xml",
width: 96,
height: 96,
brand_color: "#f97316",
is_preselected: false,
},
];
/** Antwortet auf Suche und Auswahl und merkt sich die gesendeten Anfragen. */
function mockLogoApi(kandidaten: LogoCandidate[] = KANDIDATEN) {
const anfragen: { url: string; method: string; body: string | null }[] = [];
const nachbildung = vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
anfragen.push({
url,
method: init?.method ?? "GET",
body: typeof init?.body === "string" ? init.body : null,
});
if (url.includes("/logo/search")) {
return new Response(JSON.stringify({ merchant_id: 3, candidates: kandidaten }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (url.includes("/logo/select")) {
return new Response(JSON.stringify({ ...FIRMA, logo_asset_id: 11, logo_status: "manual" }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
});
vi.stubGlobal("fetch", nachbildung);
return anfragen;
}
describe("MerchantLogoDialog", () => {
beforeEach(() => {
mockLogoApi();
});
it("sucht beim Öffnen und wählt den besten Treffer vor", async () => {
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByRole("radiogroup", { name: "Logos" })).toBeInTheDocument();
});
const auswahl = screen.getAllByRole("radio");
expect(auswahl).toHaveLength(3);
// Der Kandidat mit is_preselected ist markiert, genau einer.
expect(auswahl.filter((knopf) => knopf.getAttribute("aria-checked") === "true")).toHaveLength(1);
expect(auswahl[0]).toHaveAttribute("aria-checked", "true");
expect(screen.getByAltText("Vorschlag von Markenkatalog")).toBeInTheDocument();
expect(screen.getByAltText("Vorschlag von Erzeugt")).toBeInTheDocument();
});
it("übernimmt einen anderen Kandidaten", async () => {
const anfragen = mockLogoApi();
const nutzer = userEvent.setup();
const schliessen = vi.fn();
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={schliessen} />);
await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
await nutzer.click(screen.getAllByRole("radio")[1]!);
await nutzer.click(screen.getByRole("button", { name: "Übernehmen" }));
await waitFor(() => expect(schliessen).toHaveBeenCalled());
const auswahl = anfragen.find((eintrag) => eintrag.url.includes("/logo/select"));
expect(auswahl?.body).toBe(JSON.stringify({ candidate_id: 12 }));
});
it("sucht mit einer geänderten Domain erneut", async () => {
const anfragen = mockLogoApi();
const nutzer = userEvent.setup();
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => expect(screen.getAllByRole("radio")).toHaveLength(3));
const feld = screen.getByLabelText("Domain");
await nutzer.clear(feld);
await nutzer.type(feld, "netflix.de");
await nutzer.click(screen.getByRole("button", { name: "Suchen" }));
await waitFor(() => {
expect(
anfragen.some((eintrag) => eintrag.url.includes("domain=netflix.de")),
).toBe(true);
});
});
it("zeigt die hinterlegte Domain vor", async () => {
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByLabelText("Domain")).toHaveValue("netflix.com");
});
});
it("bietet den Upload an, wenn nichts gefunden wurde", async () => {
mockLogoApi([]);
renderWithProviders(<MerchantLogoDialog merchant={FIRMA} open onClose={vi.fn()} />);
await waitFor(() => {
expect(screen.getByText(/Keine Vorschläge/)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /Eigenes Logo hochladen/ })).toBeInTheDocument();
});
it("rendert nichts ohne Firma", () => {
const { container } = renderWithProviders(
<MerchantLogoDialog merchant={null} open onClose={vi.fn()} />,
);
expect(container).toBeEmptyDOMElement();
});
});
@@ -0,0 +1,214 @@
/**
* Auswahldialog für Firmenlogos.
*
* Die Kandidaten liefert das Backend samt Vorauswahl; alle liegen bereits im
* lokalen Cache und werden über `/api/logos/{id}` angezeigt.
*/
import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { Search, Upload } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Field, Input } from "@/components/ui/Field";
import { Skeleton } from "@/components/ui/Feedback";
import { Modal } from "@/components/ui/Modal";
import { useLogoSearch, useSelectLogo, useUploadLogo } from "@/hooks/useEntities";
import { logoUrl } from "@/lib/api";
import { cn } from "@/lib/cn";
import { toast } from "@/store/toast";
import type { LogoCandidate, LogoSource, Merchant } from "@/types/api";
const QUELLEN: Record<LogoSource, string> = {
"simple-icons": "Markenkatalog",
logodev: "logo.dev",
brandfetch: "Brandfetch",
favicon: "Favicon",
upload: "Hochgeladen",
generated: "Erzeugt",
};
const MAX_UPLOAD_BYTES = 1_048_576;
export function MerchantLogoDialog({
merchant,
open,
onClose,
}: {
merchant: Merchant | null;
open: boolean;
onClose: () => void;
}) {
const [kandidaten, setKandidaten] = useState<LogoCandidate[]>([]);
const [gewaehlt, setGewaehlt] = useState<number | null>(null);
const [domain, setDomain] = useState("");
const dateifeld = useRef<HTMLInputElement>(null);
const suchen = useLogoSearch();
const uebernehmen = useSelectLogo();
const hochladen = useUploadLogo();
// Beim Öffnen einmal automatisch suchen.
useEffect(() => {
if (!open || !merchant) return;
setDomain(merchant.domain ?? "");
setKandidaten([]);
setGewaehlt(null);
suchen.mutate(
{ merchantId: merchant.id },
{
onSuccess: (ergebnis) => {
setKandidaten(ergebnis.candidates);
const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
setGewaehlt(beste?.candidate_id ?? ergebnis.candidates[0]?.candidate_id ?? null);
},
},
);
// Nur beim Öffnen bzw. Wechsel der Firma.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, merchant?.id]);
function erneutSuchen() {
if (!merchant) return;
suchen.mutate(
{ merchantId: merchant.id, domain: domain.trim() || undefined },
{
onSuccess: (ergebnis) => {
setKandidaten(ergebnis.candidates);
const beste = ergebnis.candidates.find((eintrag) => eintrag.is_preselected);
setGewaehlt(beste?.candidate_id ?? null);
},
},
);
}
function dateiGewaehlt(ereignis: ChangeEvent<HTMLInputElement>) {
const datei = ereignis.target.files?.[0];
ereignis.target.value = "";
if (!datei || !merchant) return;
if (datei.size > MAX_UPLOAD_BYTES) {
toast.error("Die Datei ist zu groß.", "Erlaubt sind höchstens 1 MB.");
return;
}
hochladen.mutate({ merchantId: merchant.id, file: datei }, { onSuccess: onClose });
}
function speichern() {
if (!merchant || gewaehlt === null) return;
uebernehmen.mutate({ merchantId: merchant.id, candidateId: gewaehlt }, { onSuccess: onClose });
}
if (!merchant) return null;
return (
<Modal
open={open}
onClose={onClose}
title={`Logo für ${merchant.name}`}
description="Der beste Treffer ist vorausgewählt. Alle Bilder liegen bereits lokal."
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={speichern}
loading={uebernehmen.isPending}
disabled={gewaehlt === null}
>
Übernehmen
</Button>
</>
}
>
<div className="space-y-4">
<div className="flex items-end gap-2">
<Field
label="Domain"
className="flex-1"
hint="Verbessert die Trefferquote deutlich, etwa netflix.com"
>
{(id) => (
<Input
id={id}
value={domain}
placeholder="beispiel.de"
onChange={(ereignis) => setDomain(ereignis.target.value)}
onKeyDown={(ereignis) => {
if (ereignis.key === "Enter") {
ereignis.preventDefault();
erneutSuchen();
}
}}
/>
)}
</Field>
<Button onClick={erneutSuchen} loading={suchen.isPending} className="mb-[1.375rem]">
<Search aria-hidden className="h-4 w-4" />
Suchen
</Button>
</div>
{suchen.isPending ? (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Array.from({ length: 3 }, (_, index) => (
<Skeleton key={index} className="h-28" />
))}
</div>
) : kandidaten.length > 0 ? (
<ul className="grid grid-cols-2 gap-2 sm:grid-cols-3" role="radiogroup" aria-label="Logos">
{kandidaten.map((kandidat) => (
<li key={kandidat.candidate_id}>
<button
type="button"
role="radio"
aria-checked={gewaehlt === kandidat.candidate_id}
onClick={() => setGewaehlt(kandidat.candidate_id)}
className={cn(
"flex w-full flex-col items-center gap-2 rounded-card border p-3 transition",
gewaehlt === kandidat.candidate_id
? "border-accent bg-accent/10"
: "border-line bg-raised hover:border-faint",
)}
>
<img
src={logoUrl(kandidat.candidate_id)}
alt={`Vorschlag von ${QUELLEN[kandidat.source]}`}
className="h-12 w-12 object-contain"
/>
<span className="text-[11px] text-muted">{QUELLEN[kandidat.source]}</span>
{kandidat.brand_color && (
<span
aria-hidden
className="h-1 w-8 rounded-full"
style={{ backgroundColor: kandidat.brand_color }}
/>
)}
</button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-line px-3 py-6 text-center text-xs text-muted">
Keine Vorschläge. Lade stattdessen ein eigenes Bild hoch.
</p>
)}
<div className="border-t border-line pt-3">
<input
ref={dateifeld}
type="file"
accept="image/svg+xml,image/png,image/jpeg"
className="hidden"
onChange={dateiGewaehlt}
/>
<Button onClick={() => dateifeld.current?.click()} loading={hochladen.isPending}>
<Upload aria-hidden className="h-4 w-4" />
Eigenes Logo hochladen
</Button>
<p className="mt-1.5 text-xs text-faint">SVG, PNG oder JPEG bis 1 MB.</p>
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,194 @@
/** Tests des geführten RRULE-Editors. */
import { useState } from "react";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RRuleEditor } from "@/components/RRuleEditor";
import { mockFetch, renderWithProviders } from "@/test/utils";
/** Hülle, die den Zustand hält so wie es das Formular tut. */
function Huelle({
start = "FREQ=MONTHLY;BYMONTHDAY=1",
recurrenceId = null,
}: {
start?: string;
recurrenceId?: number | null;
}) {
const [regel, setRegel] = useState(start);
return (
<>
<RRuleEditor
rrule={regel}
dtstart="2026-03-01"
recurrenceId={recurrenceId}
onChange={setRegel}
/>
<output data-testid="regel">{regel}</output>
</>
);
}
describe("RRuleEditor", () => {
beforeEach(() => {
mockFetch({});
});
it("zeigt Klartext und Regel zur Vorgabe", () => {
renderWithProviders(<Huelle />);
expect(screen.getByText("Jeden 1. des Monats, ab 01.03.2026")).toBeInTheDocument();
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=1");
});
it("baut beim Wechsel auf quartalsweise die passende Regel", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Quartalsweise" }));
expect(screen.getByTestId("regel")).toHaveTextContent(
"FREQ=MONTHLY;INTERVAL=3;BYMONTHDAY=1",
);
expect(screen.getByText(/Quartalsweise am 1\./)).toBeInTheDocument();
});
it("übernimmt den gewählten Tag im Monat", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.selectOptions(screen.getByLabelText("Tag im Monat"), "15");
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=MONTHLY;BYMONTHDAY=15");
});
it("erzeugt für den Monatsletzten die BYSETPOS-Regel", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Monatsletzter" }));
expect(screen.getByTestId("regel")).toHaveTextContent(
"FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1",
);
expect(screen.getByText(/am letzten Tag/)).toBeInTheDocument();
});
it("erlaubt im Expertenmodus die freie Eingabe", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<Huelle />);
await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
const feld = screen.getByLabelText(/RRULE/);
await nutzer.clear(feld);
await nutzer.type(feld, "FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
expect(screen.getByTestId("regel")).toHaveTextContent("FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=4");
expect(screen.getByText(/Jährlich am 4\. Juni/)).toBeInTheDocument();
});
it("wählt zu einer bestehenden Regel die passende Vorlage vor", () => {
renderWithProviders(<Huelle start="FREQ=WEEKLY;BYDAY=MO,TH" />);
expect(screen.getByRole("button", { name: "Wöchentlich" })).toHaveAttribute(
"aria-pressed",
"true",
);
expect(screen.getByRole("button", { name: "Montag" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Donnerstag" })).toHaveAttribute(
"aria-pressed",
"true",
);
});
it("weist darauf hin, dass Termine erst nach dem Speichern erscheinen", () => {
renderWithProviders(<Huelle />);
expect(screen.getByText(/sobald der Posten gespeichert ist/)).toBeInTheDocument();
});
it("zeigt die vom Backend berechneten Termine", async () => {
mockFetch({
"/recurrences/7/preview": [
{
recurrence_id: 7,
recurrence_title: "Miete",
kind: "expense",
category_id: 1,
merchant_id: null,
account_id: 1,
nominal_date: "2026-03-01",
due_date: "2026-03-02",
effective_date: "2026-03-02",
amount: "950.00",
actual_amount: null,
effective_amount: "950.00",
status: "planned",
is_variable: false,
occurrence_id: null,
note: null,
installment_number: null,
installments_total: null,
},
{
recurrence_id: 7,
recurrence_title: "Miete",
kind: "expense",
category_id: 1,
merchant_id: null,
account_id: 1,
nominal_date: "2026-04-01",
due_date: "2026-04-01",
effective_date: "2026-04-01",
amount: "950.00",
actual_amount: null,
effective_amount: "950.00",
status: "planned",
is_variable: false,
occurrence_id: null,
note: null,
installment_number: null,
installments_total: null,
},
],
});
renderWithProviders(<Huelle recurrenceId={7} />);
await waitFor(() => {
expect(screen.getByText("Nächste Termine")).toBeInTheDocument();
});
// Der 01.03. ist ein Sonntag angezeigt wird der verschobene Zahltag.
expect(screen.getByText("02.03.2026")).toBeInTheDocument();
expect(screen.getByText("01.04.2026")).toBeInTheDocument();
});
it("meldet eine Regel ohne Termine", async () => {
mockFetch({ "/recurrences/7/preview": [] });
renderWithProviders(<Huelle recurrenceId={7} />);
await waitFor(() => {
expect(screen.getByText("Diese Regel ergibt keine Termine.")).toBeInTheDocument();
});
});
it("reicht einen Fehler des Servers an den Expertenmodus durch", async () => {
const nutzer = userEvent.setup();
const bei = vi.fn();
renderWithProviders(
<RRuleEditor
rrule="FREQ=QUARTERLY"
dtstart="2026-03-01"
onChange={bei}
error="Ungültige Wiederholungsregel"
/>,
);
await nutzer.click(screen.getByRole("button", { name: "Expertenmodus" }));
expect(screen.getByText("Ungültige Wiederholungsregel")).toBeInTheDocument();
});
});
+282
View File
@@ -0,0 +1,282 @@
/**
* Geführter Editor für Wiederholungsregeln mit Expertenmodus.
*
* Unabhängig vom gewählten Weg zeigt der Editor immer eine deutsche
* Klartextfassung und sobald der Posten gespeichert ist die nächsten fünf
* Termine, die das Backend tatsächlich berechnet.
*/
import { useEffect, useMemo, useState } from "react";
import { CalendarClock, Info } from "lucide-react";
import { Skeleton } from "@/components/ui/Feedback";
import { Field, Input, Select } from "@/components/ui/Field";
import { useRecurrencePreview } from "@/hooks/useEntities";
import { cn } from "@/lib/cn";
import { addMonthsIso, formatDate } from "@/lib/format";
import {
MONTHS,
PRESETS,
type PresetId,
type PresetState,
WEEKDAYS,
describeRRule,
presetFromRule,
ruleFromPreset,
} from "@/lib/rrule";
export interface RRuleEditorProps {
rrule: string;
dtstart: string;
onChange: (rrule: string) => void;
/** Ist der Posten bereits gespeichert, kommen die Termine vom Server. */
recurrenceId?: number | null;
error?: string | null;
}
export function RRuleEditor({
rrule,
dtstart,
onChange,
recurrenceId = null,
error,
}: RRuleEditorProps) {
const [zustand, setZustand] = useState<PresetState>(() => presetFromRule(rrule, dtstart));
// Wird ein anderer Posten geladen, den Editor neu aufsetzen.
useEffect(() => {
setZustand(presetFromRule(rrule, dtstart));
// Nur beim Wechsel des bearbeiteten Postens.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [recurrenceId]);
function aendern(teil: Partial<PresetState>) {
const naechster = { ...zustand, ...teil };
setZustand(naechster);
onChange(ruleFromPreset(naechster));
}
const klartext = useMemo(() => describeRRule(rrule, dtstart), [rrule, dtstart]);
return (
<div className="space-y-4">
<Field label="Wiederholung">
{(id) => (
<div id={id} className="flex flex-wrap gap-1.5" role="group" aria-label="Wiederholung">
{PRESETS.map((vorlage) => (
<button
key={vorlage.id}
type="button"
title={vorlage.hint}
aria-pressed={zustand.preset === vorlage.id}
onClick={() => aendern({ preset: vorlage.id as PresetId })}
className={cn(
"rounded-lg border px-2.5 py-1.5 text-xs font-medium transition",
zustand.preset === vorlage.id
? "border-accent bg-accent/15 text-accent"
: "border-line bg-raised text-muted hover:text-ink",
)}
>
{vorlage.label}
</button>
))}
</div>
)}
</Field>
<div className="grid gap-3 sm:grid-cols-2">
{(zustand.preset === "monthly" ||
zustand.preset === "everyNMonths" ||
zustand.preset === "quarterly" ||
zustand.preset === "yearly") && (
<Field label="Tag im Monat">
{(id) => (
<Select
id={id}
value={zustand.monthDay}
onChange={(ereignis) => aendern({ monthDay: Number(ereignis.target.value) })}
>
{Array.from({ length: 31 }, (_, index) => (
<option key={index + 1} value={index + 1}>
{index + 1}.
</option>
))}
</Select>
)}
</Field>
)}
{zustand.preset === "everyNMonths" && (
<Field label="Abstand in Monaten">
{(id) => (
<Input
id={id}
type="number"
min={1}
max={60}
value={zustand.interval}
onChange={(ereignis) => aendern({ interval: Number(ereignis.target.value) })}
/>
)}
</Field>
)}
{zustand.preset === "yearly" && (
<Field label="Monat">
{(id) => (
<Select
id={id}
value={zustand.month}
onChange={(ereignis) => aendern({ month: Number(ereignis.target.value) })}
>
{MONTHS.map((name, index) => (
<option key={name} value={index + 1}>
{name}
</option>
))}
</Select>
)}
</Field>
)}
{zustand.preset === "weekly" && (
<>
<Field label="Wochentage" className="sm:col-span-2">
{(id) => (
<div id={id} className="flex flex-wrap gap-1.5" role="group">
{WEEKDAYS.map((tag) => {
const gewaehlt = zustand.weekdays.includes(tag.code);
return (
<button
key={tag.code}
type="button"
aria-pressed={gewaehlt}
aria-label={tag.label}
onClick={() =>
aendern({
weekdays: gewaehlt
? zustand.weekdays.filter((code) => code !== tag.code)
: [...zustand.weekdays, tag.code],
})
}
className={cn(
"h-8 w-10 rounded-lg border text-xs font-medium transition",
gewaehlt
? "border-accent bg-accent/15 text-accent"
: "border-line bg-raised text-muted hover:text-ink",
)}
>
{tag.short}
</button>
);
})}
</div>
)}
</Field>
<Field label="Abstand in Wochen">
{(id) => (
<Input
id={id}
type="number"
min={1}
max={52}
value={zustand.interval}
onChange={(ereignis) => aendern({ interval: Number(ereignis.target.value) })}
/>
)}
</Field>
</>
)}
{zustand.preset === "custom" && (
<Field
label="RRULE (RFC 5545)"
className="sm:col-span-2"
hint="Ohne DTSTART. Beispiel: FREQ=MONTHLY;BYMONTHDAY=28,29,30,31;BYSETPOS=-1"
error={error}
>
{(id) => (
<Input
id={id}
value={zustand.custom}
spellCheck={false}
className="font-mono text-xs"
onChange={(ereignis) => aendern({ custom: ereignis.target.value })}
/>
)}
</Field>
)}
</div>
<div className="rounded-lg border border-line bg-raised px-3 py-2.5">
<div className="flex items-start gap-2">
<Info aria-hidden className="mt-0.5 h-3.5 w-3.5 shrink-0 text-faint" />
<div className="min-w-0">
<p className="text-sm text-ink">{klartext}</p>
<p className="mt-0.5 truncate font-mono text-[11px] text-faint">{rrule || ""}</p>
</div>
</div>
</div>
{zustand.preset !== "custom" && error && <p className="text-xs text-negative">{error}</p>}
<NextDates recurrenceId={recurrenceId} dtstart={dtstart} />
</div>
);
}
/** Die nächsten fünf Termine berechnet vom Backend, nicht im Browser. */
function NextDates({
recurrenceId,
dtstart,
}: {
recurrenceId: number | null;
dtstart: string;
}) {
const von = dtstart;
const bis = addMonthsIso(dtstart, 36);
const { data, isLoading } = useRecurrencePreview(recurrenceId, von, bis);
if (recurrenceId === null) {
return (
<p className="text-xs text-faint">
Die nächsten Termine erscheinen, sobald der Posten gespeichert ist.
</p>
);
}
if (isLoading) return <Skeleton className="h-16 w-full" />;
const termine = (data ?? []).slice(0, 5);
if (termine.length === 0) {
return <p className="text-xs text-warning">Diese Regel ergibt keine Termine.</p>;
}
return (
<div>
<p className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-muted">
<CalendarClock aria-hidden className="h-3.5 w-3.5" />
Nächste Termine
</p>
<ul className="flex flex-wrap gap-1.5">
{termine.map((termin) => (
<li
key={termin.nominal_date}
className="rounded-lg border border-line bg-raised px-2 py-1 text-xs tabular text-ink"
title={
termin.due_date !== termin.nominal_date
? `Verschoben vom ${formatDate(termin.nominal_date)}`
: undefined
}
>
{formatDate(termin.due_date)}
{termin.due_date !== termin.nominal_date && (
<span className="ml-1 text-faint"></span>
)}
</li>
))}
</ul>
</div>
);
}
@@ -0,0 +1,365 @@
/** Detailansicht eines wiederkehrenden Postens als seitlicher Drawer. */
import { type FormEvent, useState } from "react";
import { CalendarX2, Pencil, Plus, Trash2, X } from "lucide-react";
import { useCategoryLookup } from "@/hooks/useCategoryLookup";
import { MerchantLogo } from "@/components/MerchantLogo";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, Skeleton } from "@/components/ui/Feedback";
import { Field, Input } from "@/components/ui/Field";
import { MoneyInput } from "@/components/ui/MoneyInput";
import {
useAddAmountVersion,
useCancelContract,
useDeleteRecurrence,
useRecurrence,
} from "@/hooks/useEntities";
import { formatDate, formatMoney, relativeDays, todayIso } from "@/lib/format";
import { describeRRule } from "@/lib/rrule";
import type { Recurrence } from "@/types/api";
export function RecurrenceDetailDrawer({
recurrenceId,
onClose,
onEdit,
}: {
recurrenceId: number | null;
onClose: () => void;
onEdit: (recurrence: Recurrence) => void;
}) {
const { data: posten, isLoading } = useRecurrence(recurrenceId);
const entfernen = useDeleteRecurrence();
const kuendigen = useCancelContract();
const kategorieName = useCategoryLookup();
const [preisFormular, setPreisFormular] = useState(false);
const [loeschen, setLoeschen] = useState(false);
if (recurrenceId === null) return null;
return (
<div className="fixed inset-0 z-40 flex justify-end">
<div className="absolute inset-0 bg-black/50 animate-fade-in" onClick={onClose} aria-hidden />
<aside
role="dialog"
aria-modal="true"
aria-label="Details des wiederkehrenden Postens"
className="relative flex h-full w-full max-w-lg animate-slide-in-right flex-col border-l border-line bg-surface"
>
<header className="flex items-start justify-between gap-3 border-b border-line p-4">
{isLoading || !posten ? (
<Skeleton className="h-10 w-48" />
) : (
<div className="flex min-w-0 gap-3">
<MerchantLogo
merchant={
posten.merchant ?? {
name: posten.title,
logo_asset_id: null,
brand_color: null,
brand_color_dark: null,
}
}
size={40}
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{posten.title}</h2>
<p className="truncate text-xs text-muted">{kategorieName(posten.category_id)}</p>
</div>
</div>
)}
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Schließen">
<X aria-hidden className="h-4 w-4" />
</Button>
</header>
<div className="flex-1 space-y-5 overflow-y-auto p-4">
{isLoading || !posten ? (
<div className="space-y-3">
<Skeleton className="h-20 w-full" />
<Skeleton className="h-32 w-full" />
</div>
) : (
<>
<section className="grid grid-cols-2 gap-3">
<Kennzahl
label="Aktueller Betrag"
value={formatMoney(posten.amount)}
tone={posten.kind === "income" ? "positive" : "default"}
/>
<Kennzahl label="Belastung p. a." value={formatMoney(posten.annual_burden)} />
{posten.monthly_reserve && (
<Kennzahl label="Rücklage / Monat" value={formatMoney(posten.monthly_reserve)} />
)}
{posten.installments && (
<Kennzahl
label="Restschuld"
value={formatMoney(posten.installments.remaining_amount)}
/>
)}
</section>
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Wiederholung</h3>
<p className="text-sm text-ink">
{describeRRule(posten.rrule, posten.dtstart, posten.until)}
</p>
<p className="mt-0.5 font-mono text-[11px] text-faint">{posten.rrule}</p>
{posten.next_dates.length > 0 && (
<ul className="mt-2 flex flex-wrap gap-1.5">
{posten.next_dates.map((termin) => (
<li
key={termin}
className="rounded-lg border border-line bg-raised px-2 py-1 text-xs tabular text-ink"
>
{formatDate(termin)}
<span className="ml-1 text-faint">{relativeDays(termin)}</span>
</li>
))}
</ul>
)}
</section>
{posten.installments && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Ratenzahlung</h3>
<div className="rounded-lg border border-line bg-raised p-3">
<div className="flex items-center justify-between text-sm">
<span className="text-ink">
Rate {posten.installments.paid} von {posten.installments.total}
</span>
<span className="tabular text-muted">
noch {posten.installments.remaining}
</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-line">
<div
className="h-full rounded-full bg-accent transition-all"
style={{
width: `${Math.round(
(posten.installments.paid / posten.installments.total) * 100,
)}%`,
}}
/>
</div>
{posten.installments.final_due_date && (
<p className="mt-2 text-xs text-faint">
Letzte Rate am {formatDate(posten.installments.final_due_date)}
</p>
)}
</div>
</section>
)}
{posten.contract_term && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Vertrag</h3>
<dl className="space-y-1.5 rounded-lg border border-line bg-raised p-3 text-sm">
<Zeile label="Laufzeit bis" wert={formatDate(posten.contract_term.term_end)} />
{posten.contract_term.notice_deadline && (
<Zeile
label="Kündigen bis"
wert={`${formatDate(posten.contract_term.notice_deadline)} (${relativeDays(
posten.contract_term.notice_deadline,
)})`}
/>
)}
{posten.contract_term.renews_on && (
<Zeile
label="Verlängert sich am"
wert={formatDate(posten.contract_term.renews_on)}
/>
)}
{posten.contract_term.is_cancelled && (
<div className="pt-1">
<Badge tone="negative">
Gekündigt zum {formatDate(posten.contract_cancelled_at)}
</Badge>
</div>
)}
</dl>
</section>
)}
<section>
<div className="mb-1.5 flex items-center justify-between">
<h3 className="text-xs font-medium text-muted">Preishistorie</h3>
<Button size="sm" variant="ghost" onClick={() => setPreisFormular((auf) => !auf)}>
<Plus aria-hidden className="h-3.5 w-3.5" />
Preisversion
</Button>
</div>
{preisFormular && (
<AmountVersionForm
recurrenceId={posten.id}
onDone={() => setPreisFormular(false)}
/>
)}
<ul className="mt-2 divide-y divide-line rounded-lg border border-line">
{[...posten.amount_versions]
.sort((links, rechts) => rechts.valid_from.localeCompare(links.valid_from))
.map((version) => (
<li
key={version.id}
className="flex items-baseline justify-between px-3 py-2 text-sm"
>
<span className="text-muted">ab {formatDate(version.valid_from)}</span>
<span className="tabular font-medium text-ink">
{formatMoney(version.amount)}
</span>
</li>
))}
</ul>
</section>
{posten.notes && (
<section>
<h3 className="mb-1.5 text-xs font-medium text-muted">Notiz</h3>
<p className="whitespace-pre-wrap text-sm text-ink">{posten.notes}</p>
</section>
)}
</>
)}
</div>
{posten && (
<footer className="flex flex-wrap gap-2 border-t border-line p-4">
<Button onClick={() => onEdit(posten)}>
<Pencil aria-hidden className="h-4 w-4" />
Bearbeiten
</Button>
{posten.contract_term && !posten.contract_term.is_cancelled && (
<Button
onClick={() => kuendigen.mutate({ id: posten.id })}
loading={kuendigen.isPending}
>
<CalendarX2 aria-hidden className="h-4 w-4" />
Kündigen
</Button>
)}
<Button variant="danger" className="ml-auto" onClick={() => setLoeschen(true)}>
<Trash2 aria-hidden className="h-4 w-4" />
Löschen
</Button>
</footer>
)}
</aside>
<ConfirmDialog
open={loeschen}
title="Posten löschen"
description={`${posten?.title}“ wird mitsamt Preishistorie und erfassten Fälligkeiten gelöscht. Um die Historie zu erhalten, setze den Posten stattdessen auf inaktiv.`}
loading={entfernen.isPending}
onCancel={() => setLoeschen(false)}
onConfirm={() => {
if (!posten) return;
entfernen.mutate(posten.id, {
onSuccess: () => {
setLoeschen(false);
onClose();
},
});
}}
/>
</div>
);
}
function Kennzahl({
label,
value,
tone = "default",
}: {
label: string;
value: string;
tone?: "default" | "positive";
}) {
return (
<div className="rounded-lg border border-line bg-raised p-3">
<p className="text-xs text-muted">{label}</p>
<p
className={`mt-0.5 tabular text-lg font-semibold ${
tone === "positive" ? "text-positive" : "text-ink"
}`}
>
{value}
</p>
</div>
);
}
function Zeile({ label, wert }: { label: string; wert: string }) {
return (
<div className="flex justify-between gap-3">
<dt className="text-muted">{label}</dt>
<dd className="tabular text-ink">{wert}</dd>
</div>
);
}
function AmountVersionForm({
recurrenceId,
onDone,
}: {
recurrenceId: number;
onDone: () => void;
}) {
const [betrag, setBetrag] = useState("");
const [gueltigAb, setGueltigAb] = useState(todayIso());
const anlegen = useAddAmountVersion();
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!betrag) return;
anlegen.mutate(
{ recurrenceId, daten: { amount: betrag, valid_from: gueltigAb } },
{ onSuccess: onDone },
);
}
return (
<form
onSubmit={absenden}
className="mt-2 space-y-3 rounded-lg border border-line bg-raised p-3"
>
<p className="text-xs text-muted">
Ab dem Stichtag gilt der neue Betrag. Vergangene Fälligkeiten bleiben unverändert.
</p>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Neuer Betrag" required>
{(id) => <MoneyInput id={id} value={betrag} onValueChange={setBetrag} autoFocus />}
</Field>
<Field label="Gültig ab" required>
{(id) => (
<Input
id={id}
type="date"
value={gueltigAb}
onChange={(ereignis) => setGueltigAb(ereignis.target.value)}
/>
)}
</Field>
</div>
<div className="flex justify-end gap-2">
<Button size="sm" onClick={onDone}>
Abbrechen
</Button>
<Button
size="sm"
variant="primary"
onClick={absenden}
loading={anlegen.isPending}
disabled={!betrag}
>
Anlegen
</Button>
</div>
</form>
);
}
+452
View File
@@ -0,0 +1,452 @@
/** Formular für wiederkehrende Posten samt RRULE-Editor. */
import { type FormEvent, useState } from "react";
import { AccountSelect, CategorySelect, MerchantSelect } from "@/components/EntitySelects";
import { RRuleEditor } from "@/components/RRuleEditor";
import { Button } from "@/components/ui/Button";
import { Checkbox, Field, Input, Select, Textarea } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import { MoneyInput } from "@/components/ui/MoneyInput";
import { useAccounts, useSaveRecurrence } from "@/hooks/useEntities";
import { ApiError } from "@/lib/api";
import { todayIso } from "@/lib/format";
import type { BusinessDayShift, EntryKind, Recurrence } from "@/types/api";
interface FormularZustand {
kind: EntryKind;
title: string;
amount: string;
rrule: string;
dtstart: string;
until: string;
category_id: number | null;
account_id: number | null;
merchant_id: number | null;
business_day_shift: BusinessDayShift;
is_variable: boolean;
reserve_enabled: boolean;
is_active: boolean;
notes: string;
installments_total: string;
principal_amount: string;
contract_start: string;
contract_min_term_months: string;
contract_notice_period_days: string;
contract_auto_renew_months: string;
}
function leeresFormular(kontoId: number | null): FormularZustand {
return {
kind: "expense",
title: "",
amount: "",
rrule: "FREQ=MONTHLY;BYMONTHDAY=1",
dtstart: todayIso(),
until: "",
category_id: null,
account_id: kontoId,
merchant_id: null,
business_day_shift: "next",
is_variable: false,
reserve_enabled: false,
is_active: true,
notes: "",
installments_total: "",
principal_amount: "",
contract_start: "",
contract_min_term_months: "",
contract_notice_period_days: "",
contract_auto_renew_months: "",
};
}
function ausPosten(posten: Recurrence): FormularZustand {
return {
kind: posten.kind,
title: posten.title,
amount: posten.amount,
rrule: posten.rrule,
dtstart: posten.dtstart,
until: posten.until ?? "",
category_id: posten.category_id,
account_id: posten.account_id,
merchant_id: posten.merchant_id,
business_day_shift: posten.business_day_shift,
is_variable: posten.is_variable,
reserve_enabled: posten.reserve_enabled,
is_active: posten.is_active,
notes: posten.notes ?? "",
installments_total: posten.installments_total?.toString() ?? "",
principal_amount: posten.principal_amount ?? "",
contract_start: posten.contract_start ?? "",
contract_min_term_months: posten.contract_min_term_months?.toString() ?? "",
contract_notice_period_days: posten.contract_notice_period_days?.toString() ?? "",
contract_auto_renew_months: posten.contract_auto_renew_months?.toString() ?? "",
};
}
function zahlOderNull(wert: string): number | null {
const zahl = Number.parseInt(wert, 10);
return Number.isFinite(zahl) && zahl > 0 ? zahl : null;
}
export function RecurrenceFormDialog({
recurrence,
open,
onClose,
}: {
recurrence: Recurrence | null | undefined;
open: boolean;
onClose: () => void;
}) {
const { data: konten = [] } = useAccounts(true);
const speichern = useSaveRecurrence();
const [formular, setFormular] = useState<FormularZustand>(() => leeresFormular(null));
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
const [erweitert, setErweitert] = useState(false);
if (open && initialisiert !== (recurrence?.id ?? null)) {
setFormular(recurrence ? ausPosten(recurrence) : leeresFormular(konten[0]?.id ?? null));
setErweitert(Boolean(recurrence?.installments_total || recurrence?.contract_min_term_months));
setInitialisiert(recurrence?.id ?? null);
}
function setzen(teil: Partial<FormularZustand>) {
setFormular((alt) => ({ ...alt, ...teil }));
}
const absendbar =
formular.title.trim() !== "" &&
formular.amount !== "" &&
formular.rrule.trim() !== "" &&
formular.category_id !== null &&
formular.account_id !== null;
// Fehler zur Wiederholungsregel gehören an den Editor, nicht in einen Toast.
const regelFehler =
speichern.error instanceof ApiError &&
(speichern.error.code === "invalid_rrule" ||
(speichern.error.fieldMessage ?? "").toLowerCase().includes("wiederholungsregel"))
? (speichern.error.fieldMessage ?? speichern.error.message)
: null;
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
if (!absendbar) return;
speichern.mutate(
{
id: recurrence?.id,
daten: {
kind: formular.kind,
title: formular.title.trim(),
amount: formular.amount,
rrule: formular.rrule.trim(),
dtstart: formular.dtstart,
until: formular.until || null,
category_id: formular.category_id!,
account_id: formular.account_id!,
merchant_id: formular.merchant_id,
business_day_shift: formular.business_day_shift,
is_variable: formular.is_variable,
reserve_enabled: formular.reserve_enabled,
is_active: formular.is_active,
notes: formular.notes.trim() || null,
installments_total: zahlOderNull(formular.installments_total),
principal_amount: formular.principal_amount || null,
contract_start: formular.contract_start || null,
contract_min_term_months: zahlOderNull(formular.contract_min_term_months),
contract_notice_period_days: formular.contract_notice_period_days
? Number.parseInt(formular.contract_notice_period_days, 10)
: null,
contract_auto_renew_months: zahlOderNull(formular.contract_auto_renew_months),
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
size="lg"
title={recurrence ? "Posten bearbeiten" : "Neuer wiederkehrender Posten"}
description={
recurrence
? "Eine Betragsänderung hier gilt rückwirkend. Für einen Preiswechsel ab einem Stichtag lege im Detail eine Preisversion an."
: undefined
}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button
variant="primary"
onClick={absenden}
loading={speichern.isPending}
disabled={!absendbar}
>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-5">
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Richtung" required>
{(id) => (
<Select
id={id}
value={formular.kind}
onChange={(ereignis) =>
setzen({ kind: ereignis.target.value as EntryKind, category_id: null })
}
>
<option value="expense">Ausgabe</option>
<option value="income">Einkunft</option>
</Select>
)}
</Field>
<Field label="Betrag" required hint="Aktueller Betrag der Serie.">
{(id) => (
<MoneyInput
id={id}
value={formular.amount}
onValueChange={(wert) => setzen({ amount: wert })}
/>
)}
</Field>
<Field label="Titel" required className="sm:col-span-2">
{(id) => (
<Input
id={id}
value={formular.title}
required
placeholder="Netflix Standard"
onChange={(ereignis) => setzen({ title: ereignis.target.value })}
/>
)}
</Field>
<Field label="Konto" required>
{(id) => (
<AccountSelect
id={id}
required
value={formular.account_id}
onChange={(wert) => setzen({ account_id: wert })}
/>
)}
</Field>
<Field label="Kategorie" required>
{(id) => (
<CategorySelect
id={id}
required
kind={formular.kind}
value={formular.category_id}
onChange={(wert) => setzen({ category_id: wert })}
/>
)}
</Field>
<Field label="Firma">
{(id) => (
<MerchantSelect
id={id}
value={formular.merchant_id}
onChange={(wert) => setzen({ merchant_id: wert })}
/>
)}
</Field>
<Field label="Erste Fälligkeit" required>
{(id) => (
<Input
id={id}
type="date"
required
value={formular.dtstart}
onChange={(ereignis) => setzen({ dtstart: ereignis.target.value })}
/>
)}
</Field>
</div>
<div className="border-t border-line pt-4">
<RRuleEditor
rrule={formular.rrule}
dtstart={formular.dtstart}
recurrenceId={recurrence?.id ?? null}
error={regelFehler}
onChange={(regel) => setzen({ rrule: regel })}
/>
</div>
<div className="grid gap-3 border-t border-line pt-4 sm:grid-cols-2">
<Field
label="Wenn der Termin auf Wochenende oder Feiertag fällt"
className="sm:col-span-2"
>
{(id) => (
<Select
id={id}
value={formular.business_day_shift}
onChange={(ereignis) =>
setzen({ business_day_shift: ereignis.target.value as BusinessDayShift })
}
>
<option value="next">Auf den nächsten Werktag verschieben</option>
<option value="previous">Auf den vorherigen Werktag verschieben</option>
<option value="none">Nicht verschieben</option>
</Select>
)}
</Field>
<Field label="Serienende" hint="Leer lassen für unbegrenzt.">
{(id) => (
<Input
id={id}
type="date"
value={formular.until}
onChange={(ereignis) => setzen({ until: ereignis.target.value })}
/>
)}
</Field>
<div className="flex flex-col justify-end gap-2 pb-1">
<Checkbox
label="Betrag ist geschätzt"
hint="Das Ist weicht regelmäßig ab, etwa bei Strom."
checked={formular.is_variable}
onChange={(ereignis) => setzen({ is_variable: ereignis.target.checked })}
/>
<Checkbox
label="Rücklage bilden"
hint="Verteilt nicht-monatliche Posten auf zwölf Monate."
checked={formular.reserve_enabled}
onChange={(ereignis) => setzen({ reserve_enabled: ereignis.target.checked })}
/>
{recurrence && (
<Checkbox
label="Aktiv"
checked={formular.is_active}
onChange={(ereignis) => setzen({ is_active: ereignis.target.checked })}
/>
)}
</div>
</div>
<div className="border-t border-line pt-4">
<button
type="button"
onClick={() => setErweitert((offen) => !offen)}
aria-expanded={erweitert}
className="text-xs font-medium text-muted transition hover:text-ink"
>
{erweitert ? "▾" : "▸"} Raten und Vertragsdaten
</button>
{erweitert && (
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<Field label="Anzahl Raten" hint="Beendet die Serie unabhängig von der Regel.">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.installments_total}
onChange={(ereignis) => setzen({ installments_total: ereignis.target.value })}
/>
)}
</Field>
<Field label="Darlehenssumme" hint="Grundlage der Restschuldberechnung.">
{(id) => (
<MoneyInput
id={id}
value={formular.principal_amount}
onValueChange={(wert) => setzen({ principal_amount: wert })}
/>
)}
</Field>
<Field label="Vertragsbeginn">
{(id) => (
<Input
id={id}
type="date"
value={formular.contract_start}
onChange={(ereignis) => setzen({ contract_start: ereignis.target.value })}
/>
)}
</Field>
<Field label="Mindestlaufzeit in Monaten">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.contract_min_term_months}
onChange={(ereignis) =>
setzen({ contract_min_term_months: ereignis.target.value })
}
/>
)}
</Field>
<Field label="Kündigungsfrist in Tagen">
{(id) => (
<Input
id={id}
type="number"
min={0}
value={formular.contract_notice_period_days}
onChange={(ereignis) =>
setzen({ contract_notice_period_days: ereignis.target.value })
}
/>
)}
</Field>
<Field label="Verlängerung in Monaten" hint="Leer lassen, wenn keine erfolgt.">
{(id) => (
<Input
id={id}
type="number"
min={1}
value={formular.contract_auto_renew_months}
onChange={(ereignis) =>
setzen({ contract_auto_renew_months: ereignis.target.value })
}
/>
)}
</Field>
</div>
)}
</div>
<Field label="Notiz">
{(id) => (
<Textarea
id={id}
rows={2}
value={formular.notes}
onChange={(ereignis) => setzen({ notes: ereignis.target.value })}
/>
)}
</Field>
</form>
</Modal>
);
}
@@ -0,0 +1,113 @@
/** Rahmen der Anwendung: Seitenleiste, Kopfzeile und Inhaltsbereich. */
import { useState } from "react";
import { Outlet } from "react-router-dom";
import { LogOut, Menu, Moon, Sun, X } from "lucide-react";
import { Sidebar } from "@/components/layout/Sidebar";
import { Button } from "@/components/ui/Button";
import { useLogout, useMe } from "@/hooks/useAuth";
import { useThemeStore } from "@/store/theme";
export function AppLayout() {
const [menuOffen, setMenuOffen] = useState(false);
const { data: benutzer } = useMe();
const abmelden = useLogout();
const theme = useThemeStore((zustand) => zustand.theme);
const toggleTheme = useThemeStore((zustand) => zustand.toggleTheme);
return (
<div className="flex h-full">
{/* Seitenleiste ab Bildschirmbreite lg dauerhaft sichtbar */}
<aside className="hidden w-56 shrink-0 border-r border-line bg-surface lg:block">
<Sidebar />
</aside>
{menuOffen && (
<div className="fixed inset-0 z-40 lg:hidden">
<div
className="absolute inset-0 bg-black/60"
onClick={() => setMenuOffen(false)}
aria-hidden
/>
<aside className="relative h-full w-56 border-r border-line bg-surface">
<Sidebar onNavigate={() => setMenuOffen(false)} />
</aside>
</div>
)}
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-line bg-surface px-4">
<Button
variant="ghost"
size="sm"
className="lg:hidden"
onClick={() => setMenuOffen((offen) => !offen)}
aria-label={menuOffen ? "Menü schließen" : "Menü öffnen"}
>
{menuOffen ? (
<X aria-hidden className="h-4 w-4" />
) : (
<Menu aria-hidden className="h-4 w-4" />
)}
</Button>
<div className="ml-auto flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === "dark" ? "Zur hellen Ansicht wechseln" : "Zur dunklen Ansicht wechseln"}
>
{theme === "dark" ? (
<Sun aria-hidden className="h-4 w-4" />
) : (
<Moon aria-hidden className="h-4 w-4" />
)}
</Button>
{benutzer && (
<span className="hidden text-xs text-muted sm:inline">{benutzer.username}</span>
)}
<Button
variant="ghost"
size="sm"
onClick={() => abmelden.mutate()}
loading={abmelden.isPending}
aria-label="Abmelden"
>
<LogOut aria-hidden className="h-4 w-4" />
</Button>
</div>
</header>
<main className="min-w-0 flex-1 overflow-y-auto p-4 sm:p-6">
<Outlet />
</main>
</div>
</div>
);
}
/** Kopfzeile einer Seite mit Titel und optionalen Aktionen. */
export function PageHeader({
title,
description,
actions,
}: {
title: string;
description?: string;
actions?: React.ReactNode;
}) {
return (
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight text-ink">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
);
}
@@ -0,0 +1,51 @@
import { NavLink } from "react-router-dom";
import { Building2, type LucideIcon, Receipt, Repeat, Settings } from "lucide-react";
import { cn } from "@/lib/cn";
interface NavEintrag {
to: string;
label: string;
icon: LucideIcon;
}
const NAVIGATION: NavEintrag[] = [
{ to: "/recurrences", label: "Wiederkehrend", icon: Repeat },
{ to: "/transactions", label: "Buchungen", icon: Receipt },
{ to: "/merchants", label: "Firmen", icon: Building2 },
{ to: "/settings", label: "Einstellungen", icon: Settings },
];
export function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
return (
<nav aria-label="Hauptnavigation" className="flex h-full flex-col gap-1 p-3">
<div className="mb-4 flex items-center gap-2 px-2 py-1">
<span
aria-hidden
className="flex h-8 w-8 items-center justify-center rounded-lg bg-accent text-sm font-bold text-accent-ink"
>
m
</span>
<span className="text-base font-semibold tracking-tight text-ink">moneyfy</span>
</div>
{NAVIGATION.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={onNavigate}
className={({ isActive }) =>
cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition",
isActive ? "bg-accent/15 text-accent" : "text-muted hover:bg-raised hover:text-ink",
)
}
>
<Icon aria-hidden className="h-4 w-4 shrink-0" />
{label}
</NavLink>
))}
</nav>
);
}
+50
View File
@@ -0,0 +1,50 @@
import { type ButtonHTMLAttributes, forwardRef } from "react";
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/cn";
type Variant = "primary" | "secondary" | "ghost" | "danger";
type Size = "sm" | "md";
const VARIANTEN: Record<Variant, string> = {
primary: "bg-accent text-accent-ink hover:brightness-110 disabled:hover:brightness-100",
secondary: "border border-line bg-raised text-ink hover:bg-line",
ghost: "text-muted hover:bg-raised hover:text-ink",
danger: "border border-negative/40 bg-negative/10 text-negative hover:bg-negative/20",
};
const GROESSEN: Record<Size, string> = {
sm: "h-8 gap-1.5 px-2.5 text-xs",
md: "h-10 gap-2 px-4 text-sm",
};
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant;
size?: Size;
loading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ variant = "secondary", size = "md", loading = false, className, children, disabled, ...rest },
ref,
) {
return (
<button
ref={ref}
type="button"
disabled={disabled || loading}
className={cn(
"inline-flex items-center justify-center rounded-lg font-medium transition",
"disabled:cursor-not-allowed disabled:opacity-50",
VARIANTEN[variant],
GROESSEN[size],
className,
)}
{...rest}
>
{loading && <Loader2 aria-hidden className="h-4 w-4 animate-spin" />}
{children}
</button>
);
});
+115
View File
@@ -0,0 +1,115 @@
/** Ladeskelette, Leerzustände, Abzeichen und Bestätigungsdialog. */
import { type ReactNode } from "react";
import { AlertTriangle, type LucideIcon } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Modal } from "@/components/ui/Modal";
import { cn } from "@/lib/cn";
export function Skeleton({ className }: { className?: string }) {
return <div className={cn("animate-pulse rounded-md bg-raised", className)} aria-hidden />;
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2" role="status" aria-label="Daten werden geladen">
{Array.from({ length: rows }, (_, index) => (
<Skeleton key={index} className="h-14 w-full" />
))}
</div>
);
}
export interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center rounded-card border border-dashed border-line px-6 py-14 text-center">
<Icon aria-hidden className="h-9 w-9 text-faint" />
<h3 className="mt-3 text-sm font-semibold text-ink">{title}</h3>
<p className="mt-1 max-w-sm text-xs text-muted">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}
type BadgeTone = "neutral" | "accent" | "positive" | "negative" | "warning";
const TOENE: Record<BadgeTone, string> = {
neutral: "bg-raised text-muted",
accent: "bg-accent/15 text-accent",
positive: "bg-positive/15 text-positive",
negative: "bg-negative/15 text-negative",
warning: "bg-warning/15 text-warning",
};
export function Badge({
children,
tone = "neutral",
className,
}: {
children: ReactNode;
tone?: BadgeTone;
className?: string;
}) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium",
TOENE[tone],
className,
)}
>
{children}
</span>
);
}
export interface ConfirmDialogProps {
open: boolean;
title: string;
description: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
loading?: boolean;
}
export function ConfirmDialog({
open,
title,
description,
confirmLabel = "Löschen",
onConfirm,
onCancel,
loading,
}: ConfirmDialogProps) {
return (
<Modal
open={open}
onClose={onCancel}
title={title}
size="sm"
footer={
<>
<Button onClick={onCancel}>Abbrechen</Button>
<Button variant="danger" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</>
}
>
<div className="flex gap-3">
<AlertTriangle aria-hidden className="h-5 w-5 shrink-0 text-warning" />
<p className="text-sm text-muted">{description}</p>
</div>
</Modal>
);
}
+90
View File
@@ -0,0 +1,90 @@
/** Formularfeld mit Beschriftung, Hinweis und Fehlermeldung. */
import {
type InputHTMLAttributes,
type ReactNode,
type SelectHTMLAttributes,
type TextareaHTMLAttributes,
forwardRef,
useId,
} from "react";
import { cn } from "@/lib/cn";
const EINGABE_KLASSEN =
"w-full rounded-lg border border-line bg-raised px-3 py-2 text-sm text-ink " +
"placeholder:text-faint transition focus:border-accent disabled:opacity-60";
export interface FieldProps {
label: string;
hint?: string;
error?: string | null;
required?: boolean;
children: (id: string) => ReactNode;
className?: string;
}
export function Field({ label, hint, error, required, children, className }: FieldProps) {
const id = useId();
return (
<div className={cn("space-y-1.5", className)}>
<label htmlFor={id} className="block text-xs font-medium text-muted">
{label}
{required && <span className="ml-0.5 text-negative">*</span>}
</label>
{children(id)}
{error ? (
<p className="text-xs text-negative">{error}</p>
) : hint ? (
<p className="text-xs text-faint">{hint}</p>
) : null}
</div>
);
}
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
function Input({ className, ...rest }, ref) {
return <input ref={ref} className={cn(EINGABE_KLASSEN, className)} {...rest} />;
},
);
export const Select = forwardRef<HTMLSelectElement, SelectHTMLAttributes<HTMLSelectElement>>(
function Select({ className, children, ...rest }, ref) {
return (
<select ref={ref} className={cn(EINGABE_KLASSEN, "pr-8", className)} {...rest}>
{children}
</select>
);
},
);
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
function Textarea({ className, ...rest }, ref) {
return <textarea ref={ref} className={cn(EINGABE_KLASSEN, "resize-y", className)} {...rest} />;
},
);
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
label: string;
hint?: string;
}
export function Checkbox({ label, hint, className, ...rest }: CheckboxProps) {
const id = useId();
return (
<div className={cn("flex gap-2.5", className)}>
<input
id={id}
type="checkbox"
className="mt-0.5 h-4 w-4 shrink-0 rounded border-line bg-raised text-accent accent-[rgb(var(--color-accent))]"
{...rest}
/>
<div className="min-w-0">
<label htmlFor={id} className="block cursor-pointer text-sm text-ink">
{label}
</label>
{hint && <p className="text-xs text-faint">{hint}</p>}
</div>
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
/** Modaler Dialog mit Fokusfalle und Schließen per Escape. */
import { type ReactNode, useCallback, useEffect, useRef } from "react";
import { X } from "lucide-react";
import { Button } from "@/components/ui/Button";
import { cn } from "@/lib/cn";
export interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
footer?: ReactNode;
size?: "sm" | "md" | "lg";
}
const BREITEN = { sm: "max-w-md", md: "max-w-2xl", lg: "max-w-4xl" };
export function Modal({
open,
onClose,
title,
description,
children,
footer,
size = "md",
}: ModalProps) {
const dialog = useRef<HTMLDivElement>(null);
const tastatur = useCallback(
(ereignis: KeyboardEvent) => {
if (ereignis.key === "Escape") {
onClose();
return;
}
if (ereignis.key !== "Tab" || !dialog.current) return;
// Fokus im Dialog halten.
const fokussierbar = dialog.current.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
);
const erstes = fokussierbar[0];
const letztes = fokussierbar[fokussierbar.length - 1];
if (!erstes || !letztes) return;
if (ereignis.shiftKey && document.activeElement === erstes) {
ereignis.preventDefault();
letztes.focus();
} else if (!ereignis.shiftKey && document.activeElement === letztes) {
ereignis.preventDefault();
erstes.focus();
}
},
[onClose],
);
useEffect(() => {
if (!open) return;
document.addEventListener("keydown", tastatur);
const vorher = document.body.style.overflow;
document.body.style.overflow = "hidden";
// Erstes Eingabefeld bekommt den Fokus.
const ziel = dialog.current?.querySelector<HTMLElement>("input, select, textarea, button");
ziel?.focus();
return () => {
document.removeEventListener("keydown", tastatur);
document.body.style.overflow = vorher;
};
}, [open, tastatur]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 sm:p-8">
<div
className="fixed inset-0 bg-black/60 animate-fade-in"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-label={title}
className={cn(
"relative z-10 w-full animate-slide-up rounded-card border border-line bg-surface shadow-2xl",
BREITEN[size],
)}
>
<header className="flex items-start justify-between gap-4 border-b border-line px-5 py-4">
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-ink">{title}</h2>
{description && <p className="mt-0.5 text-xs text-muted">{description}</p>}
</div>
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Dialog schließen">
<X aria-hidden className="h-4 w-4" />
</Button>
</header>
<div className="px-5 py-4">{children}</div>
{footer && (
<footer className="flex justify-end gap-2 border-t border-line px-5 py-3">{footer}</footer>
)}
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
/**
* Betragsfeld in deutscher Schreibweise.
*
* Nach außen fließt immer das API-Format ("13.99"), angezeigt wird "13,99".
*/
import { type InputHTMLAttributes, useEffect, useState } from "react";
import { Input } from "@/components/ui/Field";
import { parseAmountInput, toAmountInput } from "@/lib/format";
export interface MoneyInputProps
extends Omit<InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type"> {
value: string;
onValueChange: (value: string) => void;
}
export function MoneyInput({ value, onValueChange, ...rest }: MoneyInputProps) {
const [text, setText] = useState(() => toAmountInput(value));
// Von außen gesetzte Werte übernehmen, solange der Nutzer nicht gerade tippt.
useEffect(() => {
const normalisiert = parseAmountInput(text);
if (normalisiert !== value) setText(toAmountInput(value));
// Absichtlich nur auf `value` reagieren.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
return (
<div className="relative">
<Input
inputMode="decimal"
placeholder="0,00"
value={text}
onChange={(ereignis) => {
const eingabe = ereignis.target.value;
setText(eingabe);
onValueChange(parseAmountInput(eingabe) ?? "");
}}
onBlur={() => setText(toAmountInput(parseAmountInput(text) ?? ""))}
className="pr-8 tabular"
{...rest}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-sm text-faint">
</span>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
/** Zeigt die Meldungen aus dem Toast-Store an. */
import { CheckCircle2, Info, X, XCircle } from "lucide-react";
import { cn } from "@/lib/cn";
import { type ToastKind, useToastStore } from "@/store/toast";
const SYMBOLE = { success: CheckCircle2, error: XCircle, info: Info };
const RAHMEN: Record<ToastKind, string> = {
success: "border-positive/40 text-positive",
error: "border-negative/40 text-negative",
info: "border-line text-muted",
};
export function Toaster() {
const toasts = useToastStore((zustand) => zustand.toasts);
const dismiss = useToastStore((zustand) => zustand.dismiss);
if (toasts.length === 0) return null;
return (
<div
aria-live="polite"
aria-atomic="false"
className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-2"
>
{toasts.map((eintrag) => {
const Symbol = SYMBOLE[eintrag.kind];
return (
<div
key={eintrag.id}
role={eintrag.kind === "error" ? "alert" : "status"}
className={cn(
"pointer-events-auto flex animate-slide-up items-start gap-3 rounded-card border bg-surface p-3 shadow-xl",
RAHMEN[eintrag.kind],
)}
>
<Symbol aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-ink">{eintrag.title}</p>
{eintrag.description && (
<p className="mt-0.5 break-words text-xs text-muted">{eintrag.description}</p>
)}
</div>
<button
type="button"
onClick={() => dismiss(eintrag.id)}
aria-label="Meldung schließen"
className="shrink-0 rounded p-0.5 text-faint transition hover:text-ink"
>
<X aria-hidden className="h-3.5 w-3.5" />
</button>
</div>
);
})}
</div>
);
}