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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user