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