feat(notifications): täglicher Lauf über SMTP und Apprise

- Kanäle SMTP (HTML-Mail mit Logos als CID-Anhang) und Apprise, beide
  blockierenden Bibliotheken laufen in einem Thread
- Vier Anlässe: Fälligkeiten im Vorlauf, Kündigungsfristen in drei Stufen,
  überschrittene Budgets je Monat, Vertragsverlängerungen im Folgemonat
- APScheduler im Anwendungsprozess, täglich 07:00 Europe/Berlin, räumt zugleich
  abgelaufene Sitzungen auf
- Duplikatsschutz über den Zieltag statt den Versandtag; fehlgeschlagener
  Versand wird beim nächsten Lauf erneut versucht
- Endpunkte für Regeln, Protokoll, Testversand und sofortigen Lauf
- Einstellungsseite mit Einrichtungsstand, Regelpflege und Protokoll
- 25 neue Backend-Tests (260 gesamt), 9 neue Frontend-Tests (74 gesamt)

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 17:04:30 +02:00
co-authored by Claude Opus 5
parent 0adf154049
commit 54c59c9f71
17 changed files with 2500 additions and 1 deletions
@@ -0,0 +1,214 @@
/** Tests der Benachrichtigungs-Einstellungen. */
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NotificationSettings } from "@/components/NotificationSettings";
import { renderWithProviders } from "@/test/utils";
import type { NotificationRule, NotificationSettings as Einstellungen } from "@/types/api";
const EINSTELLUNGEN: Einstellungen = {
enabled: true,
scheduler_enabled: true,
run_at: "07:00",
timezone: "Europe/Berlin",
next_run_at: "2026-03-11T07:00:00+01:00",
channels: [
{ channel: "smtp", configured: true, detail: "Einsatzbereit." },
{ channel: "apprise", configured: false, detail: "APPRISE_URLS setzen, komma-separiert." },
],
};
const REGEL: NotificationRule = {
id: 1,
type: "due_soon",
channel: "smtp",
lead_days: 3,
target: null,
is_active: true,
created_at: "2026-03-01T10:00:00+01:00",
};
function mockApi(
overrides: {
rules?: NotificationRule[];
/** "sent" liefert Erfolg, "failed" einen Fehler, "unconfigured" gar keinen Kanal. */
test?: "sent" | "failed" | "unconfigured";
} = {},
) {
const anfragen: { url: string; method: string; body: unknown }[] = [];
const json = (daten: unknown, status = 200) =>
new Response(JSON.stringify(daten), {
status,
headers: { "content-type": "application/json" },
});
vi.stubGlobal(
"fetch",
vi.fn(async (eingabe: RequestInfo | URL, init?: RequestInit) => {
const url = typeof eingabe === "string" ? eingabe : eingabe.toString();
const methode = init?.method ?? "GET";
anfragen.push({
url,
method: methode,
body: typeof init?.body === "string" ? JSON.parse(init.body) : null,
});
if (url.includes("/notifications/settings")) return json(EINSTELLUNGEN);
if (url.includes("/notifications/log")) return json([]);
if (url.includes("/notifications/test")) {
const modus = overrides.test ?? "sent";
const smtp =
modus === "sent"
? { channel: "smtp", configured: true, sent: true, error: null }
: modus === "failed"
? { channel: "smtp", configured: true, sent: false, error: "Server weg" }
: { channel: "smtp", configured: false, sent: false, error: null };
return json({
results: [smtp, { channel: "apprise", configured: false, sent: false, error: null }],
any_sent: modus === "sent",
});
}
if (url.includes("/notifications/run")) {
return json({ checked: 4, sent: 2, skipped: 2, failed: 0 });
}
if (url.includes("/notifications/rules")) {
if (methode === "POST") return json({ ...REGEL, id: 9 }, 201);
return json(overrides.rules ?? [REGEL]);
}
return json({});
}),
);
return anfragen;
}
describe("Benachrichtigungs-Einstellungen", () => {
beforeEach(() => {
mockApi();
});
it("zeigt Zeitplan und Einrichtungsstand der Kanäle", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => {
expect(screen.getByText(/Täglicher Lauf um 07:00 Uhr/)).toBeInTheDocument();
});
// "E-Mail" steht sowohl auf der Kanalkarte als auch als Abzeichen an der Regel.
expect(screen.getAllByText("E-Mail").length).toBeGreaterThan(0);
expect(screen.getByText("Einsatzbereit.")).toBeInTheDocument();
// Der nicht eingerichtete Kanal nennt die fehlende Variable.
expect(screen.getByText(/APPRISE_URLS setzen/)).toBeInTheDocument();
});
it("listet Regeln mit Anlass, Kanal und Vorlauf", async () => {
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Bald fällig")).toBeInTheDocument());
expect(screen.getByText("3 Tage Vorlauf")).toBeInTheDocument();
expect(screen.getByText(/Meldet Fälligkeiten innerhalb der Vorlaufzeit/)).toBeInTheDocument();
});
it("meldet einen leeren Regelsatz verständlich", async () => {
mockApi({ rules: [] });
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Keine Regeln")).toBeInTheDocument());
expect(screen.getByText(/verschickt moneyfy nichts/)).toBeInTheDocument();
});
it("löst den Testversand aus", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/test"))).toBe(true);
});
// Der Erfolg wird sichtbar zurückgemeldet.
expect(await screen.findByText(/Testnachricht versendet über E-Mail/)).toBeInTheDocument();
});
it("meldet einen fehlgeschlagenen Testversand mit Ursache", async () => {
mockApi({ test: "failed" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Testversand ist fehlgeschlagen/)).toBeInTheDocument();
expect(screen.getByText("Server weg")).toBeInTheDocument();
});
it("weist auf fehlende Kanäle hin, statt einen Fehler zu melden", async () => {
mockApi({ test: "unconfigured" });
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Testnachricht/ }));
expect(await screen.findByText(/Kein Kanal eingerichtet/)).toBeInTheDocument();
});
it("startet einen Lauf und meldet das Ergebnis", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Zeitplan")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /Jetzt prüfen/ }));
await waitFor(() => {
expect(anfragen.some((eintrag) => eintrag.url.includes("/notifications/run"))).toBe(true);
});
expect(await screen.findByText(/2 Benachrichtigungen versendet/)).toBeInTheDocument();
expect(screen.getByText(/4 geprüft, 2 bereits gemeldet/)).toBeInTheDocument();
});
it("legt eine Regel über das Formular an", async () => {
const anfragen = mockApi();
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "notice_deadline");
await nutzer.selectOptions(within(dialog).getByLabelText(/Kanal/), "apprise");
await nutzer.click(within(dialog).getByRole("button", { name: "Speichern" }));
await waitFor(() => {
const angelegt = anfragen.find(
(eintrag) => eintrag.method === "POST" && eintrag.url.includes("/notifications/rules"),
);
expect(angelegt?.body).toMatchObject({
type: "notice_deadline",
channel: "apprise",
is_active: true,
});
});
});
it("blendet den Vorlauf nur bei Fälligkeiten ein", async () => {
const nutzer = userEvent.setup();
renderWithProviders(<NotificationSettings />);
await waitFor(() => expect(screen.getByText("Regeln")).toBeInTheDocument());
await nutzer.click(screen.getByRole("button", { name: /^Regel$/ }));
const dialog = await screen.findByRole("dialog", { name: "Neue Regel" });
expect(within(dialog).getByLabelText(/Vorlauf in Tagen/)).toBeInTheDocument();
// Für ein Budget gibt es keinen Vorlauf.
await nutzer.selectOptions(within(dialog).getByLabelText(/Anlass/), "budget_exceeded");
expect(within(dialog).queryByLabelText(/Vorlauf in Tagen/)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,390 @@
/** Einstellungen der Benachrichtigungen: Kanäle, Regeln, Testversand, Protokoll. */
import { type FormEvent, useState } from "react";
import {
BellRing,
CheckCircle2,
Play,
Plus,
Send,
Trash2,
XCircle,
} from "lucide-react";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
import { Checkbox, Field, Input, Select } from "@/components/ui/Field";
import { Modal } from "@/components/ui/Modal";
import {
useDeleteNotificationRule,
useNotificationLog,
useNotificationRules,
useNotificationSettings,
useRunNotifications,
useSaveNotificationRule,
useSendTestNotification,
} from "@/hooks/useNotifications";
import { formatDate } from "@/lib/format";
import type { NotificationChannel, NotificationRule, NotificationType } from "@/types/api";
const TYP_NAMEN: Record<NotificationType, string> = {
due_soon: "Bald fällig",
notice_deadline: "Kündigungsfrist",
budget_exceeded: "Budget überschritten",
contract_renewal: "Vertragsverlängerung",
};
const TYP_HINWEISE: Record<NotificationType, string> = {
due_soon: "Meldet Fälligkeiten innerhalb der Vorlaufzeit.",
notice_deadline: "Meldet 30, 14 und 7 Tage vor dem letzten Kündigungstermin.",
budget_exceeded: "Meldet überschrittene Budgets, einmal je Monat und Kategorie.",
contract_renewal: "Meldet Verträge, die sich im kommenden Monat verlängern.",
};
const KANAL_NAMEN: Record<NotificationChannel, string> = {
smtp: "E-Mail",
apprise: "Apprise",
};
export function NotificationSettings() {
const [formularOffen, setFormularOffen] = useState(false);
const [bearbeiten, setBearbeiten] = useState<NotificationRule | null>(null);
const [loeschen, setLoeschen] = useState<NotificationRule | null>(null);
const { data: einstellungen, isLoading } = useNotificationSettings();
const { data: regeln = [] } = useNotificationRules();
const { data: protokoll = [] } = useNotificationLog(25);
const entfernen = useDeleteNotificationRule();
const testen = useSendTestNotification();
const laufStarten = useRunNotifications();
if (isLoading || !einstellungen) return <Skeleton className="h-64 w-full" />;
return (
<div className="space-y-4">
<section className="card p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="flex items-center gap-2 text-sm font-semibold text-ink">
<BellRing aria-hidden className="h-4 w-4" />
Zeitplan
</h2>
<p className="mt-0.5 text-xs text-muted">
{einstellungen.scheduler_enabled
? `Täglicher Lauf um ${einstellungen.run_at} Uhr (${einstellungen.timezone}).`
: "Der Scheduler ist abgeschaltet (SCHEDULER_ENABLED=false)."}
{einstellungen.next_run_at &&
` Nächster Lauf am ${formatDate(einstellungen.next_run_at.slice(0, 10))}.`}
</p>
</div>
<div className="flex gap-2">
<Button onClick={() => testen.mutate(undefined)} loading={testen.isPending}>
<Send aria-hidden className="h-4 w-4" />
Testnachricht
</Button>
<Button onClick={() => laufStarten.mutate()} loading={laufStarten.isPending}>
<Play aria-hidden className="h-4 w-4" />
Jetzt prüfen
</Button>
</div>
</div>
<ul className="mt-3 grid gap-2 sm:grid-cols-2">
{einstellungen.channels.map((kanal) => (
<li
key={kanal.channel}
className="flex items-start gap-2 rounded-lg border border-line bg-raised p-3"
>
{kanal.configured ? (
<CheckCircle2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-positive" />
) : (
<XCircle aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-faint" />
)}
<div className="min-w-0">
<p className="text-sm font-medium text-ink">{KANAL_NAMEN[kanal.channel]}</p>
<p className="text-xs text-muted">{kanal.detail}</p>
</div>
</li>
))}
</ul>
</section>
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-sm font-semibold text-ink">Regeln</h2>
<Button
size="sm"
onClick={() => {
setBearbeiten(null);
setFormularOffen(true);
}}
>
<Plus aria-hidden className="h-3.5 w-3.5" />
Regel
</Button>
</div>
{regeln.length === 0 ? (
<EmptyState
icon={BellRing}
title="Keine Regeln"
description="Ohne Regel verschickt moneyfy nichts. Lege fest, worüber du informiert werden willst."
/>
) : (
<ul className="divide-y divide-line rounded-card border border-line">
{regeln.map((regel) => (
<li key={regel.id} className="group flex items-center gap-3 px-3 py-2.5">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium text-ink">{TYP_NAMEN[regel.type]}</span>
<Badge>{KANAL_NAMEN[regel.channel]}</Badge>
{regel.type === "due_soon" && (
<Badge tone="accent">{regel.lead_days} Tage Vorlauf</Badge>
)}
{!regel.is_active && <Badge tone="warning">Inaktiv</Badge>}
</div>
<p className="truncate text-xs text-faint">
{TYP_HINWEISE[regel.type]}
{regel.target && ` An: ${regel.target}`}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button
size="sm"
variant="ghost"
onClick={() => {
setBearbeiten(regel);
setFormularOffen(true);
}}
>
Ändern
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setLoeschen(regel)}
aria-label={`Regel ${TYP_NAMEN[regel.type]} löschen`}
>
<Trash2 aria-hidden className="h-3.5 w-3.5" />
</Button>
</div>
</li>
))}
</ul>
)}
</section>
{protokoll.length > 0 && (
<section>
<h2 className="mb-2 text-sm font-semibold text-ink">Versandprotokoll</h2>
<div className="overflow-x-auto rounded-card border border-line">
<table className="w-full text-xs">
<caption className="sr-only">Zuletzt versendete Benachrichtigungen</caption>
<thead className="border-b border-line text-left text-muted">
<tr>
<th scope="col" className="px-3 py-2 font-medium">
Gesendet
</th>
<th scope="col" className="px-3 py-2 font-medium">
Bezug
</th>
<th scope="col" className="px-3 py-2 font-medium">
Zieltag
</th>
<th scope="col" className="px-3 py-2 font-medium">
Status
</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
{protokoll.map((eintrag) => (
<tr key={eintrag.id}>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.sent_at.slice(0, 10))}
</td>
<td className="px-3 py-1.5 text-muted">
{eintrag.ref_type} {eintrag.ref_id}
</td>
<td className="whitespace-nowrap px-3 py-1.5 tabular text-muted">
{formatDate(eintrag.dedupe_day)}
</td>
<td className="px-3 py-1.5">
{eintrag.status === "sent" ? (
<Badge tone="positive">zugestellt</Badge>
) : (
<Badge tone="negative" className="max-w-xs truncate">
{eintrag.error ?? "fehlgeschlagen"}
</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<RuleDialog
rule={bearbeiten}
open={formularOffen}
onClose={() => {
setFormularOffen(false);
setBearbeiten(null);
}}
/>
<ConfirmDialog
open={loeschen !== null}
title="Regel löschen"
description="Die Regel wird samt ihrem Versandprotokoll entfernt."
loading={entfernen.isPending}
onCancel={() => setLoeschen(null)}
onConfirm={() => {
if (loeschen) entfernen.mutate(loeschen.id, { onSuccess: () => setLoeschen(null) });
}}
/>
</div>
);
}
function RuleDialog({
rule,
open,
onClose,
}: {
rule: NotificationRule | null;
open: boolean;
onClose: () => void;
}) {
const speichern = useSaveNotificationRule();
const [typ, setTyp] = useState<NotificationType>("due_soon");
const [kanal, setKanal] = useState<NotificationChannel>("smtp");
const [vorlauf, setVorlauf] = useState(7);
const [ziel, setZiel] = useState("");
const [aktiv, setAktiv] = useState(true);
const [initialisiert, setInitialisiert] = useState<number | null | undefined>(undefined);
if (open && initialisiert !== (rule?.id ?? null)) {
setTyp(rule?.type ?? "due_soon");
setKanal(rule?.channel ?? "smtp");
setVorlauf(rule?.lead_days ?? 7);
setZiel(rule?.target ?? "");
setAktiv(rule?.is_active ?? true);
setInitialisiert(rule?.id ?? null);
}
function absenden(ereignis: FormEvent) {
ereignis.preventDefault();
speichern.mutate(
{
id: rule?.id,
daten: rule
? { channel: kanal, lead_days: vorlauf, target: ziel.trim() || null, is_active: aktiv }
: {
type: typ,
channel: kanal,
lead_days: vorlauf,
target: ziel.trim() || null,
is_active: aktiv,
},
},
{
onSuccess: () => {
setInitialisiert(undefined);
onClose();
},
},
);
}
return (
<Modal
open={open}
onClose={onClose}
size="sm"
title={rule ? "Regel ändern" : "Neue Regel"}
footer={
<>
<Button onClick={onClose}>Abbrechen</Button>
<Button variant="primary" onClick={absenden} loading={speichern.isPending}>
Speichern
</Button>
</>
}
>
<form onSubmit={absenden} className="space-y-3">
<Field label="Anlass" required hint={TYP_HINWEISE[typ]}>
{(id) => (
<Select
id={id}
value={typ}
disabled={rule !== null}
onChange={(ereignis) => setTyp(ereignis.target.value as NotificationType)}
>
{(Object.keys(TYP_NAMEN) as NotificationType[]).map((wert) => (
<option key={wert} value={wert}>
{TYP_NAMEN[wert]}
</option>
))}
</Select>
)}
</Field>
<Field label="Kanal" required>
{(id) => (
<Select
id={id}
value={kanal}
onChange={(ereignis) => setKanal(ereignis.target.value as NotificationChannel)}
>
<option value="smtp">E-Mail</option>
<option value="apprise">Apprise</option>
</Select>
)}
</Field>
{typ === "due_soon" && (
<Field label="Vorlauf in Tagen" hint="Wie früh vor der Fälligkeit gemeldet wird.">
{(id) => (
<Input
id={id}
type="number"
min={0}
max={365}
value={vorlauf}
onChange={(ereignis) => setVorlauf(Number(ereignis.target.value))}
/>
)}
</Field>
)}
<Field
label="Ziel"
hint={
kanal === "smtp"
? "Mailadresse; leer lassen für SMTP_FROM."
: "Apprise-URL; leer lassen für APPRISE_URLS."
}
>
{(id) => (
<Input
id={id}
value={ziel}
placeholder={kanal === "smtp" ? "ich@example.org" : "ntfy://host/topic"}
onChange={(ereignis) => setZiel(ereignis.target.value)}
/>
)}
</Field>
<Checkbox
label="Aktiv"
checked={aktiv}
onChange={(ereignis) => setAktiv(ereignis.target.checked)}
/>
</form>
</Modal>
);
}
+121
View File
@@ -0,0 +1,121 @@
/** Benachrichtigungsregeln, Protokoll und Testversand. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { toast } from "@/store/toast";
import type {
MessageResponse,
NotificationLogEntry,
NotificationRule,
NotificationRuleInput,
NotificationRunResult,
NotificationSettings,
TestSendResponse,
} from "@/types/api";
const KANAL_NAMEN = { smtp: "E-Mail", apprise: "Apprise" } as const;
export function useNotificationSettings() {
return useQuery({
queryKey: ["notifications", "settings"],
queryFn: () => api.get<NotificationSettings>("/notifications/settings"),
});
}
export function useNotificationRules() {
return useQuery({
queryKey: ["notifications", "rules"],
queryFn: () => api.get<NotificationRule[]>("/notifications/rules"),
});
}
export function useNotificationLog(limit = 50) {
return useQuery({
queryKey: ["notifications", "log", limit],
queryFn: () => api.get<NotificationLogEntry[]>("/notifications/log", { limit }),
});
}
export function useSaveNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: ({
id,
daten,
}: {
id?: number;
daten: NotificationRuleInput | Partial<NotificationRuleInput>;
}) =>
id
? api.patch<NotificationRule>(`/notifications/rules/${id}`, daten)
: api.post<NotificationRule>("/notifications/rules", daten),
onSuccess: (_regel, variablen) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(variablen.id ? "Regel gespeichert." : "Regel angelegt.");
},
});
}
export function useDeleteNotificationRule() {
const client = useQueryClient();
return useMutation({
mutationFn: (id: number) => api.del<MessageResponse>(`/notifications/rules/${id}`),
onSuccess: () => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success("Regel gelöscht.");
},
});
}
export function useSendTestNotification() {
return useMutation({
mutationFn: (target?: string) =>
api.post<TestSendResponse>("/notifications/test", { target: target || null }),
onSuccess: (ergebnis) => {
const zugestellt = ergebnis.results
.filter((eintrag) => eintrag.sent)
.map((eintrag) => KANAL_NAMEN[eintrag.channel]);
const gescheitert = ergebnis.results.filter(
(eintrag) => eintrag.configured && !eintrag.sent,
);
if (zugestellt.length > 0) {
toast.success(
`Testnachricht versendet über ${zugestellt.join(" und ")}.`,
gescheitert.length > 0
? `Fehlgeschlagen: ${gescheitert
.map((eintrag) => `${KANAL_NAMEN[eintrag.channel]} (${eintrag.error})`)
.join(", ")}`
: undefined,
);
return;
}
if (gescheitert.length > 0) {
toast.error(
"Der Testversand ist fehlgeschlagen.",
gescheitert.map((eintrag) => eintrag.error).join(" · "),
);
return;
}
toast.info("Kein Kanal eingerichtet.", "Trage SMTP- oder Apprise-Daten in die Umgebung ein.");
},
});
}
export function useRunNotifications() {
const client = useQueryClient();
return useMutation({
mutationFn: () => api.post<NotificationRunResult>("/notifications/run"),
onSuccess: (ergebnis) => {
void client.invalidateQueries({ queryKey: ["notifications"] });
toast.success(
ergebnis.sent > 0
? `${ergebnis.sent} Benachrichtigung${ergebnis.sent === 1 ? "" : "en"} versendet.`
: "Nichts zu melden.",
`${ergebnis.checked} geprüft, ${ergebnis.skipped} bereits gemeldet.`,
);
},
});
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { type FormEvent, useState } from "react";
import { KeyRound, Pencil, Plus, Trash2, Wallet } from "lucide-react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { PageHeader } from "@/components/layout/AppLayout";
import { Button } from "@/components/ui/Button";
import { Badge, ConfirmDialog, EmptyState, Skeleton } from "@/components/ui/Feedback";
@@ -31,11 +32,12 @@ const KONTOARTEN: Record<AccountType, string> = {
cash: "Bargeld",
};
type Reiter = "accounts" | "categories" | "account";
type Reiter = "accounts" | "categories" | "notifications" | "account";
const REITER: { id: Reiter; label: string }[] = [
{ id: "accounts", label: "Konten" },
{ id: "categories", label: "Kategorien" },
{ id: "notifications", label: "Benachrichtigungen" },
{ id: "account", label: "Konto & Darstellung" },
];
@@ -67,6 +69,7 @@ export function SettingsPage() {
{reiter === "accounts" && <AccountsSection />}
{reiter === "categories" && <CategoriesSection />}
{reiter === "notifications" && <NotificationSettings />}
{reiter === "account" && <UserSection />}
</>
);
+4
View File
@@ -4,9 +4,13 @@ import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
import { useToastStore } from "@/store/toast";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
// Der Toast-Store lebt global ohne Zurücksetzen tropfen Meldungen in den nächsten Test.
useToastStore.getState().clear();
});
// jsdom kennt matchMedia nicht; einzelne Komponenten fragen es ab.
+4
View File
@@ -6,6 +6,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { Toaster } from "@/components/ui/Toaster";
/** Frischer Client je Test, ohne Wiederholungen und ohne Konsolenausgabe. */
export function createTestQueryClient(): QueryClient {
return new QueryClient({
@@ -27,6 +29,8 @@ export function renderWithProviders(ui: ReactElement, route = "/") {
future={{ v7_startTransition: true, v7_relativeSplatPath: true }}
>
{children}
{/* Wie in der echten Anwendung so lassen sich Rückmeldungen prüfen. */}
<Toaster />
</MemoryRouter>
</QueryClientProvider>
);
+72
View File
@@ -529,3 +529,75 @@ export interface Dashboard {
upcoming: CalendarEntry[];
upcoming_deadlines: Subscription[];
}
export type NotificationType =
| "due_soon"
| "notice_deadline"
| "budget_exceeded"
| "contract_renewal";
export type NotificationChannel = "smtp" | "apprise";
export type NotificationStatus = "sent" | "failed";
export interface NotificationRule {
id: number;
type: NotificationType;
channel: NotificationChannel;
lead_days: number;
target: string | null;
is_active: boolean;
created_at: IsoDateTime;
}
export interface NotificationRuleInput {
type: NotificationType;
channel: NotificationChannel;
lead_days?: number;
target?: string | null;
is_active?: boolean;
}
export interface NotificationLogEntry {
id: number;
rule_id: number;
ref_type: string;
ref_id: string;
dedupe_day: IsoDate;
sent_at: IsoDateTime;
status: NotificationStatus;
error: string | null;
}
export interface ChannelStatus {
channel: NotificationChannel;
configured: boolean;
detail: string;
}
export interface NotificationSettings {
enabled: boolean;
scheduler_enabled: boolean;
run_at: string;
timezone: string;
next_run_at: IsoDateTime | null;
channels: ChannelStatus[];
}
export interface TestResult {
channel: NotificationChannel;
configured: boolean;
sent: boolean;
error: string | null;
}
export interface TestSendResponse {
results: TestResult[];
any_sent: boolean;
}
export interface NotificationRunResult {
checked: number;
sent: number;
skipped: number;
failed: number;
}