Phase 7: Benachrichtigungen und Termine

Terminkalender mit Serienterminen, Selbstanmeldung, Warteliste und
Anwesenheitsliste. Vier Zustellwege hinter Schnittstellen: echtes SMTP,
ntfy (selbst gehostet), Telegram und Web-Push. Kalenderabonnement als
ICS-Feed, digitaler Dienstausweis mit signiertem QR und Pruefseite.

Niemand wird ungefragt benachrichtigt: ein neuer Zustellweg hat eine leere
Ereignisliste, und der Seed legt bewusst keine an. Was das Netz der Wehr
verlaesst, bekommt nur die Kurzfassung ohne Namen und Details.

Kein CalDAV (Lesen genuegt, ICS kann jeder Kalender) und kein WhatsApp
(Verarbeitung ueber Meta, Kosten je Konversation, Telefonbuchabgleich) --
beides im README begruendet.

56 neue Unit-Tests, 17 neue E2E-Tests, Version 0.8.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-09-04 08:39:38 +02:00
co-authored by Claude Opus 5
parent c779266450
commit 0b8874da26
134 changed files with 9340 additions and 123 deletions
+133
View File
@@ -0,0 +1,133 @@
"use client";
import * as React from "react";
import Image from "next/image";
import { useRouter } from "next/navigation";
import { RefreshCw, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { alsDatum } from "@/lib/utils";
/**
* Die Ansicht des Dienstausweises.
*
* Sie ist bewusst wie ein Ausweis aufgebaut und nicht wie eine Datenmaske:
* groß der Name, darunter Wehr und Funktion, daneben der Code. Wer ihn
* vorzeigt, hält ein Telefon hin — der Gegenüber soll in zwei Sekunden
* erfassen, wen er vor sich hat.
*
* Der Code läuft nach fünf Minuten ab. Statt ihn stur zu erneuern zeigt die
* Seite an, wie lange er noch gilt, und holt sich kurz vorher einen neuen.
*/
export function AusweisAnzeige({
bild,
name,
dienstgrad,
wehr,
qualifikationen,
gueltigSekunden,
}: {
bild: string;
name: string;
dienstgrad: string | null;
wehr: string;
qualifikationen: Array<{ name: string; kuerzel: string | null; gueltigBis: Date | null }>;
gueltigSekunden: number;
}) {
const router = useRouter();
const [rest, setRest] = React.useState(gueltigSekunden);
React.useEffect(() => {
setRest(gueltigSekunden);
const zeitgeber = setInterval(() => {
setRest((wert) => {
if (wert <= 1) {
router.refresh();
return gueltigSekunden;
}
return wert - 1;
});
}, 1000);
return () => clearInterval(zeitgeber);
}, [bild, gueltigSekunden, router]);
const minuten = Math.floor(rest / 60);
const sekunden = rest % 60;
return (
<div className="mx-auto max-w-md space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Dienstausweis</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Zum Vorzeigen an der Absperrung, beim Lehrgang oder bei einer Kontrolle.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-xl">{name}</CardTitle>
<CardDescription>
{dienstgrad ? `${dienstgrad} · ` : ""}
{wehr}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Image
src={bild}
alt="Dienstausweis als QR-Code"
width={420}
height={420}
unoptimized
className="mx-auto h-auto w-full max-w-xs rounded-lg border border-[var(--border)] bg-white p-3"
/>
<p className="text-center text-sm text-[var(--muted-foreground)]" aria-live="polite">
Gültig noch {minuten > 0 ? `${minuten}:${String(sekunden).padStart(2, "0")} Minuten` : `${sekunden} Sekunden`}
</p>
<div className="h-2 overflow-hidden rounded-full bg-[var(--muted)]">
<div
className="h-full bg-[var(--primary)] transition-all duration-1000"
style={{ width: `${(rest / gueltigSekunden) * 100}%` }}
role="presentation"
/>
</div>
{qualifikationen.length > 0 && (
<div className="space-y-2 border-t border-[var(--border)] pt-4">
<p className="text-sm font-medium">Gültige Qualifikationen</p>
<ul className="flex flex-wrap gap-2">
{qualifikationen.map((eintrag) => (
<li key={`${eintrag.name}-${eintrag.gueltigBis?.toISOString() ?? "unbefristet"}`}>
<Badge variant="secondary" title={eintrag.name}>
{eintrag.kuerzel ?? eintrag.name}
{eintrag.gueltigBis ? ` bis ${alsDatum(eintrag.gueltigBis)}` : ""}
</Badge>
</li>
))}
</ul>
</div>
)}
<Button variant="outline" className="w-full" onClick={() => router.refresh()}>
<RefreshCw aria-hidden />
Neuen Code anzeigen
</Button>
</CardContent>
</Card>
<div className="flex gap-3 rounded-lg border border-[var(--border)] p-4 text-sm text-[var(--muted-foreground)]">
<ShieldCheck className="mt-0.5 size-5 shrink-0" aria-hidden />
<p>
Der Code enthält keine Daten über dich, sondern eine kurzlebige Unterschrift. Wer ihn
scannt, sieht Name, Wehr, Funktion und die gültigen Qualifikationen — nicht mehr. Nach
fünf Minuten ist er wertlos, ein Foto davon nützt also nichts.
</p>
</div>
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { env } from "@/lib/env";
import { AUSWEIS_GUELTIG_SEKUNDEN, ausweisErzeugen } from "@/lib/dienstausweis";
import { qrTextAlsDataUri } from "@/lib/stammdaten/qr";
import { dienstgradText } from "@/lib/stammdaten/texte";
import { AusweisAnzeige } from "@/app/(app)/ausweis/anzeige";
export const metadata = { title: "Dienstausweis" };
export const dynamic = "force-dynamic";
/**
* Der digitale Dienstausweis.
*
* Was ein Ausweis aus Karton kann, kann diese Seite auch: vorzeigen, wer man
* ist und zu welcher Wehr man gehört. Der Unterschied ist, dass der Prüfende
* den Code scannt und die Angaben von FLORIAN bestätigt bekommt statt von
* einem laminierten Stück Papier.
*
* Der Code wird auf dem Server signiert — mit AUTH_SECRET, das im Browser
* nichts zu suchen hat — und gilt fünf Minuten.
*/
export default async function AusweisSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const person = await db.person.findUnique({
where: { id: sitzung.personId },
select: {
vorname: true,
nachname: true,
dienstgrad: true,
eintrittsdatum: true,
organisation: { select: { name: true, kurzname: true } },
qualifikationen: {
where: {
geloeschtAm: null,
OR: [{ gueltigBis: null }, { gueltigBis: { gte: new Date() } }],
},
orderBy: { erworbenAm: "desc" },
select: { gueltigBis: true, typ: { select: { name: true, kuerzel: true } } },
},
},
});
return person;
});
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
const bild = await qrTextAlsDataUri(adresse, 420);
return (
<AusweisAnzeige
bild={bild}
name={daten ? `${daten.vorname} ${daten.nachname}` : sitzung.name}
dienstgrad={dienstgradText(daten?.dienstgrad)}
wehr={daten?.organisation.name ?? ""}
qualifikationen={daten?.qualifikationen.map((eintrag) => ({
name: eintrag.typ.name,
kuerzel: eintrag.typ.kuerzel,
gueltigBis: eintrag.gueltigBis,
})) ?? []}
gueltigSekunden={AUSWEIS_GUELTIG_SEKUNDEN}
/>
);
}
@@ -0,0 +1,546 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { BellRing, Copy, Send, Trash2, TriangleAlert } from "lucide-react";
import { EreignisTyp, Kanal } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
import type { AppRouter } from "@/server/routers/_app";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen";
import { ZUSTELLSTAND_TEXT } from "@/lib/termin/texte";
import { pushAbonnieren, pushMoeglich } from "@/lib/benachrichtigung/push-browser";
import { alsDatumZeit } from "@/lib/utils";
/**
* Die Einstellseite.
*
* Aufgebaut nach dem, was jemand nacheinander tun will:
*
* 1. Einen Weg einrichten (und ihn sofort ausprobieren).
* 2. Ankreuzen, wofür er benutzt wird.
* 3. Den Kalender abonnieren.
*
* Der Probeknopf steht bewusst direkt an jedem Weg. Ohne ihn merkt jemand
* erst in drei Wochen, dass sein ntfy-Thema einen Tippfehler hat — dann,
* wenn die Nachricht wichtig gewesen wäre.
*/
export function BenachrichtigungEinstellungen({ darfVorlagen }: { darfVorlagen: boolean }) {
const werkzeuge = trpc.useUtils();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const moeglichkeiten = trpc.benachrichtigung.moeglichkeiten.useQuery();
const kanaele = trpc.benachrichtigung.eigeneKanaele.useQuery();
const verlauf = trpc.benachrichtigung.eigeneListe.useQuery({ anzahl: 20 });
const aendern = trpc.benachrichtigung.kanalAendern.useMutation({
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.benachrichtigung.kanalLoeschen.useMutation({
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const probe = trpc.benachrichtigung.probe.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const ereignisse = moeglichkeiten.data?.ereignisse ?? [];
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="benachrichtigungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<Card>
<CardHeader>
<CardTitle>Deine Zustellwege</CardTitle>
<CardDescription>
Ohne einen Weg hier bekommst du nichts — außer dem, was in FLORIAN selbst steht.
Niemand wird ungefragt benachrichtigt.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kanaele.data?.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Noch kein Zustellweg eingerichtet.
</p>
)}
{kanaele.data?.map((kanal) => (
<div
key={kanal.id}
className="space-y-3 rounded-lg border border-[var(--border)] p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{KANAL_TEXT[kanal.kanal]}</span>
{kanal.bezeichnung && (
<span className="text-sm text-[var(--muted-foreground)]">
{kanal.bezeichnung}
</span>
)}
{KANAL_EXTERN[kanal.kanal] && (
<Badge variant="pruefungFaellig" title="Läuft über einen fremden Server">
<TriangleAlert className="mr-1 size-3" aria-hidden />
extern
</Badge>
)}
{!kanal.aktiv && <Badge variant="gesperrt">stillgelegt</Badge>}
</div>
<p className="text-xs text-[var(--muted-foreground)]">
{kanal.letzteZustellung
? `Zuletzt zugestellt: ${alsDatumZeit(kanal.letzteZustellung)}`
: "Noch nichts zugestellt."}
{kanal.fehlversuche > 0 && ` · ${kanal.fehlversuche} Fehlversuche`}
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={probe.isPending}
onClick={() => {
setHinweis(null);
probe.mutate({ id: kanal.id });
}}
>
<Send aria-hidden />
Probe
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: kanal.id })}
aria-label={`${KANAL_TEXT[kanal.kanal]} entfernen`}
>
<Trash2 aria-hidden />
</Button>
</div>
</div>
{KANAL_EXTERN[kanal.kanal] && (
<p className="text-xs text-[var(--muted-foreground)]">
Über diesen Weg gehen nur Kurzfassungen ohne Namen, Adressen und
Einsatzdetails — er verlässt das Netz der Wehr.
</p>
)}
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Wofür?</legend>
<div className="grid gap-2 sm:grid-cols-2">
{ereignisse.map((ereignis) => {
const an = kanal.ereignisse.includes(ereignis.wert);
return (
<label key={ereignis.wert} className="flex items-center gap-2 text-sm">
<Checkbox
checked={an}
onCheckedChange={(wert) =>
aendern.mutate({
id: kanal.id,
ereignisse:
wert === true
? [...kanal.ereignisse, ereignis.wert]
: kanal.ereignisse.filter((e) => e !== ereignis.wert),
})
}
/>
{ereignis.text}
</label>
);
})}
</div>
</fieldset>
</div>
))}
<KanalAnlegen
moeglichkeiten={moeglichkeiten.data}
fertig={() => void werkzeuge.benachrichtigung.invalidate()}
fehler={setMeldung}
/>
</CardContent>
</Card>
<Kalenderzugaenge fehler={setMeldung} />
<Card>
<CardHeader>
<CardTitle>Zuletzt an dich verschickt</CardTitle>
<CardDescription>
Was FLORIAN dir geschickt hat — und ob es angekommen ist.
</CardDescription>
</CardHeader>
<CardContent>
{verlauf.data?.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Noch nichts verschickt.</p>
) : (
<ul className="space-y-2 text-sm">
{verlauf.data?.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-baseline justify-between gap-2 border-b border-[var(--border)] pb-2"
>
<span className="min-w-0">
<span className="font-medium">{eintrag.betreff ?? "(ohne Betreff)"}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {KANAL_TEXT[eintrag.kanal]} · {alsDatumZeit(eintrag.erstelltAm)}
</span>
{eintrag.fehler && (
<span className="block text-[var(--destructive)]">{eintrag.fehler}</span>
)}
</span>
<Badge
variant={
eintrag.stand === "GESENDET"
? "einsatzbereit"
: eintrag.stand === "FEHLGESCHLAGEN"
? "gesperrt"
: "secondary"
}
>
{ZUSTELLSTAND_TEXT[eintrag.stand]}
</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{darfVorlagen && (
<Card>
<CardHeader>
<CardTitle>Textvorlagen der Wehr</CardTitle>
<CardDescription>
Was in den Nachrichten steht, gilt für alle. Ohne eigene Vorlage greifen die
eingebauten Texte.
</CardDescription>
</CardHeader>
<CardContent>
<Button variant="outline" asChild>
<Link href="/benachrichtigungen/vorlagen">Vorlagen bearbeiten</Link>
</Button>
</CardContent>
</Card>
)}
</div>
);
}
/**
* Was die Oberfläche an Kanälen und Ereignissen anbieten darf.
*
* Direkt aus dem Router abgeleitet: kommt dort ein Kanal dazu, taucht er hier
* ohne Zutun auf — und wenn eines der Felder wegfällt, meckert der Compiler.
*/
type Moeglichkeiten = inferRouterOutputs<AppRouter>["benachrichtigung"]["moeglichkeiten"];
/**
* Einen neuen Weg einrichten.
*
* Jeder Kanal braucht etwas anderes, und das ist der Grund, warum es hier ein
* Formular gibt statt eines einzelnen Knopfes:
*
* E-Mail nichts — es gilt die hinterlegte Adresse
* Telegram die Chat-Kennung, die der Bot ausgibt
* ntfy ein Thema, das man selbst wählt
* Web-Push gar nichts von Hand: der Browser fragt und liefert die Daten
*/
function KanalAnlegen({
moeglichkeiten,
fertig,
fehler,
}: {
moeglichkeiten: Moeglichkeiten | undefined;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kanal, setKanal] = React.useState<Kanal>(Kanal.MAIL);
const [ziel, setZiel] = React.useState("");
const [bezeichnung, setBezeichnung] = React.useState("");
const anlegen = trpc.benachrichtigung.kanalAnlegen.useMutation({
onSuccess: () => {
setZiel("");
setBezeichnung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
const eingerichtet =
moeglichkeiten?.kanaele.find((eintrag) => eintrag.wert === kanal)?.eingerichtet ?? true;
async function speichern() {
// Web-Push ist der einzige Weg, bei dem der Browser die Adresse liefert:
// er fragt die Person um Erlaubnis und meldet sich beim Push-Dienst an.
if (kanal === Kanal.WEBPUSH) {
if (!moeglichkeiten?.webPushSchluessel) {
fehler("Für Web-Push fehlen die Schlüssel auf dem Server (WEBPUSH_PUBLIC_KEY).");
return;
}
const abo = await pushAbonnieren(moeglichkeiten.webPushSchluessel).catch((grund: Error) => {
fehler(grund.message);
return null;
});
if (!abo) return;
anlegen.mutate({
kanal,
ziel: abo.endpunkt,
p256dh: abo.p256dh,
auth: abo.auth,
bezeichnung: bezeichnung || "Dieser Browser",
ereignisse: [],
});
return;
}
anlegen.mutate({
kanal,
ziel: kanal === Kanal.MAIL ? "" : ziel,
bezeichnung: bezeichnung || null,
ereignisse: [],
});
}
return (
<div className="space-y-3 rounded-lg border border-dashed border-[var(--border)] p-4">
<p className="flex items-center gap-2 text-sm font-medium">
<BellRing className="size-4" aria-hidden />
Weg hinzufügen
</p>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="kanal-art">Über</Label>
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
<SelectTrigger id="kanal-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{moeglichkeiten?.kanaele.map((eintrag) => (
<SelectItem key={eintrag.wert} value={eintrag.wert}>
{eintrag.text}
{!eintrag.eingerichtet && " (nicht eingerichtet)"}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && (
<div className="space-y-2">
<Label htmlFor="kanal-ziel">
{kanal === Kanal.TELEGRAM ? "Chat-Kennung" : "Thema"}
</Label>
<Input
id="kanal-ziel"
value={ziel}
onChange={(ereignis) => setZiel(ereignis.target.value)}
placeholder={
kanal === Kanal.TELEGRAM
? "123456789"
: (moeglichkeiten?.ntfyVorschlag ?? "florian-…")
}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="kanal-bezeichnung">Name (freiwillig)</Label>
<Input
id="kanal-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Diensthandy"
/>
</div>
</div>
{kanal === Kanal.NTFY && moeglichkeiten?.ntfyServer && (
<p className="text-xs text-[var(--muted-foreground)]">
In der ntfy-App den Server <code>{moeglichkeiten.ntfyServer}</code> eintragen und
dieses Thema abonnieren. Der Themenname ist das Geheimnis — wer ihn kennt, liest mit.
Deshalb einen langen, zufälligen nehmen.
</p>
)}
{kanal === Kanal.WEBPUSH && !pushMoeglich() && (
<p className="text-xs text-[var(--muted-foreground)]">
Dieser Browser kann kein Web-Push. Auf dem iPhone geht es erst, wenn FLORIAN zum
Startbildschirm hinzugefügt wurde.
</p>
)}
{!eingerichtet && (
<p className="text-xs text-[var(--pruefung-faellig)]">
Dieser Weg ist auf dem Server nicht eingerichtet. Du kannst ihn eintragen, aber es
wird nichts ankommen, bis der Administrator ihn aktiviert.
</p>
)}
<Button
size="sm"
onClick={() => void speichern()}
disabled={
anlegen.isPending ||
(kanal !== Kanal.MAIL && kanal !== Kanal.WEBPUSH && !ziel.trim())
}
>
{anlegen.isPending ? "Wird eingerichtet …" : "Einrichten"}
</Button>
</div>
);
}
/** Die Kalenderadressen fürs Handy. */
function Kalenderzugaenge({ fehler }: { fehler: (nachricht: string) => void }) {
const werkzeuge = trpc.useUtils();
const [bezeichnung, setBezeichnung] = React.useState("");
const [nurEigene, setNurEigene] = React.useState(false);
const [neueAdresse, setNeueAdresse] = React.useState<string | null>(null);
const zugaenge = trpc.benachrichtigung.kalenderzugaenge.useQuery();
const anlegen = trpc.benachrichtigung.kalenderzugangAnlegen.useMutation({
onSuccess: (ergebnis) => {
setNeueAdresse(ergebnis.adresse);
setBezeichnung("");
void werkzeuge.benachrichtigung.invalidate();
},
onError: (aufruf) => fehler(aufruf.message),
});
const widerrufen = trpc.benachrichtigung.kalenderzugangWiderrufen.useMutation({
onSuccess: () => void werkzeuge.benachrichtigung.invalidate(),
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Card>
<CardHeader>
<CardTitle>Kalender abonnieren</CardTitle>
<CardDescription>
Die Termine der Wehr im Kalender des Handys — neben allem anderen, ohne FLORIAN zu
öffnen. Die Adresse ist ein Schlüssel: wer sie hat, sieht die Termine.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{neueAdresse && (
<div className="space-y-2 rounded-md border border-[var(--primary)] bg-[var(--muted)] p-3">
<p className="text-sm font-medium">
Diese Adresse wird nur jetzt angezeigt — bitte gleich eintragen.
</p>
<div className="flex gap-2">
<Input readOnly value={neueAdresse} className="font-mono text-xs" />
<Button
variant="outline"
size="icon"
aria-label="Adresse kopieren"
onClick={() => void navigator.clipboard?.writeText(neueAdresse)}
>
<Copy aria-hidden />
</Button>
</div>
</div>
)}
{zugaenge.data?.map((zugang) => (
<div
key={zugang.id}
className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--border)] pb-2 text-sm"
>
<div>
<span className="font-medium">{zugang.bezeichnung}</span>
{zugang.nurEigene && (
<Badge variant="secondary" className="ml-2">
nur eigene
</Badge>
)}
{!zugang.aktiv && (
<Badge variant="gesperrt" className="ml-2">
widerrufen
</Badge>
)}
<span className="block text-xs text-[var(--muted-foreground)]">
{zugang.zugriffe} Abrufe
{zugang.letzterZugriff && `, zuletzt ${alsDatumZeit(zugang.letzterZugriff)}`}
</span>
</div>
{zugang.aktiv && (
<Button
size="sm"
variant="ghost"
onClick={() => widerrufen.mutate({ id: zugang.id })}
>
Widerrufen
</Button>
)}
</div>
))}
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-2">
<Label htmlFor="kalender-name">Für welches Gerät?</Label>
<Input
id="kalender-name"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Mein Handy"
/>
</div>
<label className="flex items-center gap-2 pb-2 text-sm">
<Checkbox
checked={nurEigene}
onCheckedChange={(wert) => setNurEigene(wert === true)}
/>
Nur Termine mit meiner Rückmeldung
</label>
<Button
className="mb-0.5"
disabled={!bezeichnung.trim() || anlegen.isPending}
onClick={() => anlegen.mutate({ bezeichnung, nurEigene })}
>
Adresse erzeugen
</Button>
</div>
</CardContent>
</Card>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BenachrichtigungEinstellungen } from "@/app/(app)/benachrichtigungen/einstellungen";
export const metadata = { title: "Benachrichtigungen" };
/**
* Die eigenen Benachrichtigungswege.
*
* Der Satz, der über der ganzen Seite steht und der auch die Technik darunter
* bestimmt: **niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
* bekommt nichts. Es gibt keine Voreinstellung, die alle anmeldet.
*/
export default async function BenachrichtigungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Benachrichtigungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wo du worüber informiert werden willst — und der Kalender fürs Handy.
</p>
</div>
<BenachrichtigungEinstellungen
darfVorlagen={hatRecht(sitzung, "benachrichtigung.vorlagen")}
/>
</div>
);
}
@@ -0,0 +1,271 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, RotateCcw } from "lucide-react";
import { EreignisTyp, Kanal } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { EREIGNIS_TEXT, KANAL_EXTERN, KANAL_TEXT } from "@/lib/benachrichtigung/vorlagen";
/**
* Vorlagen bearbeiten.
*
* Der Aufbau folgt dem, was schiefgehen kann: links der Text, rechts die
* Vorschau mit Beispielwerten. Ein Platzhalter, den es für dieses Ereignis
* nicht gibt, bleibt in der Vorschau als `{tippfehler}` stehen — und wird beim
* Speichern abgelehnt, mit der Liste der erlaubten daneben.
*/
export function VorlagenBearbeiten() {
const werkzeuge = trpc.useUtils();
const [ereignis, setEreignis] = React.useState<EreignisTyp>(EreignisTyp.TERMIN_NEU);
const [kanal, setKanal] = React.useState<Kanal>(Kanal.MAIL);
const [betreff, setBetreff] = React.useState("");
const [text, setText] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(null);
const [gespeichert, setGespeichert] = React.useState(false);
/**
* Den passenden Text laden — aber nur einmal je Auswahl.
*
* Der Merker ist wichtiger, als er aussieht: die Liste wird im Hintergrund
* nachgeladen (nach dem Speichern, beim Zurückkommen ins Fenster). Ohne ihn
* würde jedes Nachladen den Text überschreiben, den gerade jemand tippt —
* ein Fehler, der sich beim Ausprobieren kaum zeigt und im Alltag ständig.
*/
const geladenFuer = React.useRef<string | null>(null);
const vorlagen = trpc.benachrichtigung.vorlagen.useQuery();
const vorschau = trpc.benachrichtigung.vorschau.useQuery(
{ ereignis, kanal, betreff: betreff || null, text },
{ enabled: text.length > 0 },
);
const speichern = trpc.benachrichtigung.vorlageSpeichern.useMutation({
onSuccess: () => {
setGespeichert(true);
setMeldung(null);
void werkzeuge.benachrichtigung.invalidate();
},
onError: (fehler) => {
setGespeichert(false);
setMeldung(fehler.message);
},
});
const zuruecksetzen = trpc.benachrichtigung.vorlageZuruecksetzen.useMutation({
onSuccess: () => {
// Den Merker freigeben, damit der eingebaute Text wieder eingelesen wird.
geladenFuer.current = null;
void werkzeuge.benachrichtigung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const eigene = vorlagen.data?.eigene.find(
(eintrag) => eintrag.ereignis === ereignis && eintrag.kanal === kanal,
);
const standard = vorlagen.data?.standard.find((eintrag) => eintrag.ereignis === ereignis);
React.useEffect(() => {
if (!vorlagen.data) return;
const auswahl = `${ereignis}:${kanal}`;
if (geladenFuer.current === auswahl) return;
geladenFuer.current = auswahl;
setGespeichert(false);
setMeldung(null);
if (eigene) {
setBetreff(eigene.betreff ?? "");
setText(eigene.text);
return;
}
if (standard) {
setBetreff(standard.lang.betreff);
setText(KANAL_EXTERN[kanal] ? standard.lang.kurz : standard.lang.text);
}
}, [ereignis, kanal, eigene, standard, vorlagen.data]);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/benachrichtigungen">
<ArrowLeft aria-hidden />
Zurück zu den Benachrichtigungen
</Link>
</Button>
<div className="flex flex-wrap gap-4">
<div className="space-y-2">
<Label htmlFor="vorlage-ereignis">Wofür</Label>
<Select value={ereignis} onValueChange={(wert) => setEreignis(wert as EreignisTyp)}>
<SelectTrigger id="vorlage-ereignis" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(EreignisTyp).map((wert) => (
<SelectItem key={wert} value={wert}>
{EREIGNIS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="vorlage-kanal">Über</Label>
<Select value={kanal} onValueChange={(wert) => setKanal(wert as Kanal)}>
<SelectTrigger id="vorlage-kanal" className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Kanal).map((wert) => (
<SelectItem key={wert} value={wert}>
{KANAL_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{KANAL_EXTERN[kanal] && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<strong>{KANAL_TEXT[kanal]} verlässt das Netz der Wehr.</strong> Was hier steht, läuft
über einen fremden Server. Deshalb gehören dort keine Namen, keine Adressen und keine
Einsatzdetails hinein — ein Hinweis genügt, das Ausführliche steht in FLORIAN.
</div>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Text</CardTitle>
<CardDescription>
{eigene
? "Eine eigene Vorlage ist hinterlegt."
: "Noch keine eigene Vorlage — hier steht der eingebaute Text."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{kanal === Kanal.MAIL && (
<div className="space-y-2">
<Label htmlFor="vorlage-betreff">Betreff</Label>
<Input
id="vorlage-betreff"
value={betreff}
onChange={(ereignisDaten) => setBetreff(ereignisDaten.target.value)}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="vorlage-text">Nachricht</Label>
<Textarea
id="vorlage-text"
rows={10}
value={text}
onChange={(ereignisDaten) => setText(ereignisDaten.target.value)}
className="font-mono text-sm"
/>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Verfügbare Platzhalter</p>
<div className="flex flex-wrap gap-1.5">
{standard?.platzhalter.map((name) => (
<button
key={name}
type="button"
onClick={() => setText((bisher) => `${bisher}{${name}}`)}
title="Anhängen"
>
<Badge variant="outline" className="font-mono">
{`{${name}}`}
</Badge>
</button>
))}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="vorlagenfehler" />}
{gespeichert && (
<p className="text-sm text-[var(--einsatzbereit)]">Vorlage gespeichert.</p>
)}
<div className="flex gap-2">
<Button
onClick={() =>
speichern.mutate({
ereignis,
kanal,
betreff: kanal === Kanal.MAIL ? betreff || null : null,
text,
aktiv: true,
})
}
disabled={!text.trim() || speichern.isPending}
>
{speichern.isPending ? "Wird gespeichert …" : "Speichern"}
</Button>
{eigene && (
<Button
variant="outline"
onClick={() => zuruecksetzen.mutate({ id: eigene.id })}
disabled={zuruecksetzen.isPending}
>
<RotateCcw aria-hidden />
Auf Standard zurücksetzen
</Button>
)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vorschau</CardTitle>
<CardDescription>
Mit erfundenen Beispielwerten. Bleibt ein Platzhalter stehen, gibt es ihn für
dieses Ereignis nicht.
</CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-4">
{kanal === Kanal.MAIL && vorschau.data?.betreff && (
<p className="mb-3 border-b border-[var(--border)] pb-2 font-medium">
{vorschau.data.betreff}
</p>
)}
<p className="whitespace-pre-wrap text-sm">
{vorschau.data?.text ?? "Noch kein Text."}
</p>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { VorlagenBearbeiten } from "@/app/(app)/benachrichtigungen/vorlagen/bearbeiten";
export const metadata = { title: "Textvorlagen" };
/**
* Die Textvorlagen der Wehr.
*
* Was hier steht, bekommen alle. Deshalb hängt die Seite an einem eigenen
* Recht und nicht am Grundrecht, mit dem jeder seine eigenen Wege einstellt.
*/
export default async function VorlagenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "benachrichtigung.vorlagen")) redirect("/benachrichtigungen");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Textvorlagen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Was in den Benachrichtigungen steht. Ohne eigene Vorlage greift der eingebaute Text.
</p>
</div>
<VorlagenBearbeiten />
</div>
);
}
+1 -14
View File
@@ -9,23 +9,10 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
export const metadata = { title: "Mitglied" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/**
* Mitgliedsdetail.
*
+1 -14
View File
@@ -14,23 +14,10 @@ import {
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
export const metadata = { title: "Mitglieder" };
const DIENSTGRAD_TEXT: Record<string, string> = {
FEUERWEHRANWAERTER: "Feuerwehranwärter",
FEUERWEHRMANN: "Feuerwehrmann",
OBERFEUERWEHRMANN: "Oberfeuerwehrmann",
HAUPTFEUERWEHRMANN: "Hauptfeuerwehrmann",
LOESCHMEISTER: "Löschmeister",
OBERLOESCHMEISTER: "Oberlöschmeister",
HAUPTLOESCHMEISTER: "Hauptlöschmeister",
BRANDMEISTER: "Brandmeister",
OBERBRANDMEISTER: "Oberbrandmeister",
HAUPTBRANDMEISTER: "Hauptbrandmeister",
KEIN: "—",
};
/**
* Mitgliederliste, nach Abteilung gruppiert.
*
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { TerminDetail } from "@/app/(app)/termine/[id]/termin-detail";
export const metadata = { title: "Termin" };
export default async function TerminSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
return (
<TerminDetail
id={id}
darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")}
darfAnwesenheit={hatRecht(sitzung, "termin.anwesenheit")}
/>
);
}
@@ -0,0 +1,500 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ArrowLeft, ClipboardList, Printer, Repeat } from "lucide-react";
import { Teilnahmestand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { TEILNAHME_TEXT, TERMINART_TEXT, nochHin } from "@/lib/termin/texte";
import { alsDatum, alsUhrzeit } from "@/lib/utils";
/**
* Ein einzelner Termin.
*
* Drei Dinge passieren hier, und sie gehören zu verschiedenen Zeitpunkten:
* zusagen (vorher), absagen (vorher, durch die Leitung), Anwesenheit abhaken
* (nachher). Die Ansicht zeigt deshalb nach dem Termin einen anderen
* Schwerpunkt als davor.
*/
export function TerminDetail({
id,
darfBearbeiten,
darfAnwesenheit,
}: {
id: string;
darfBearbeiten: boolean;
darfAnwesenheit: boolean;
}) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [absageOffen, setAbsageOffen] = React.useState(false);
const [listeOffen, setListeOffen] = React.useState(false);
const termin = trpc.termin.einzeln.useQuery({ id });
const rueckmelden = trpc.termin.rueckmelden.useMutation({
onSuccess: () => void werkzeuge.termin.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
if (termin.isLoading) {
return <div className="h-64 animate-pulse rounded-lg bg-[var(--muted)]" aria-hidden />;
}
if (termin.error || !termin.data) {
return <Fehlermeldung meldung={termin.error?.message ?? "Termin nicht gefunden."} />;
}
const daten = termin.data;
const abgesagt = Boolean(daten.abgesagtAm);
const vorbei = daten.beginn.getTime() < Date.now();
const zugesagt = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ZUGESAGT);
const warteliste = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.WARTELISTE);
const abgemeldet = daten.anmeldungen.filter((e) => e.stand === Teilnahmestand.ABGESAGT);
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild className="nicht-drucken">
<Link href="/termine">
<ArrowLeft aria-hidden />
Zurück zu den Terminen
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{daten.titel}</h1>
<Badge variant="outline">{TERMINART_TEXT[daten.art]}</Badge>
{daten.serieId && (
<Badge variant="secondary">
<Repeat className="mr-1 size-3" aria-hidden />
Teil einer Serie
</Badge>
)}
{abgesagt && <Badge variant="gesperrt">Abgesagt</Badge>}
</div>
<p className="text-[var(--muted-foreground)]">
{alsDatum(daten.beginn)}
{!daten.ganztaegig && `, ${alsUhrzeit(daten.beginn)} Uhr`}
{daten.ende && !daten.ganztaegig && ` bis ${alsUhrzeit(daten.ende)} Uhr`}
{!vorbei && ` · ${nochHin(daten.beginn)}`}
</p>
</div>
<div className="flex flex-wrap gap-2 nicht-drucken">
<Button variant="outline" onClick={() => setListeOffen(true)}>
<ClipboardList aria-hidden />
Anwesenheitsliste
</Button>
{darfBearbeiten && !abgesagt && (
<Button variant="outline" onClick={() => setAbsageOffen(true)}>
Termin absagen
</Button>
)}
</div>
</div>
{abgesagt && (
<div
role="alert"
className="rounded-md border border-[var(--gesperrt)]/40 bg-[var(--gesperrt)]/10 p-4 text-sm"
>
<p className="font-medium">Dieser Termin fällt aus.</p>
{daten.absageGrund && <p className="mt-1">Grund: {daten.absageGrund}</p>}
</div>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<Feld titel="Ort" wert={daten.ort} />
<Feld
titel="Leitung"
wert={daten.leiter ? `${daten.leiter.vorname} ${daten.leiter.nachname}` : null}
/>
<Feld
titel="Rückmeldung"
wert={daten.anmeldungNoetig ? "wird erwartet" : "nicht nötig"}
/>
<Feld
titel="Plätze"
wert={
daten.teilnehmerLimit
? `${zugesagt.length} von ${daten.teilnehmerLimit} belegt`
: "unbegrenzt"
}
/>
{daten.anmeldeschluss && (
<Feld titel="Anmeldeschluss" wert={alsDatum(daten.anmeldeschluss)} />
)}
</dl>
{daten.beschreibung && (
<div className="border-t border-[var(--border)] pt-4">
<p className="whitespace-pre-wrap text-sm">{daten.beschreibung}</p>
</div>
)}
</CardContent>
</Card>
<Card className="nicht-drucken">
<CardHeader>
<CardTitle>Deine Rückmeldung</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{abgesagt ? (
<p className="text-sm text-[var(--muted-foreground)]">
Der Termin ist abgesagt — eine Rückmeldung ändert daran nichts.
</p>
) : (
<>
<p className="text-sm">
{daten.eigenerStand
? `Aktuell: ${TEILNAHME_TEXT[daten.eigenerStand]}`
: "Noch keine Rückmeldung."}
</p>
<div className="flex gap-2">
<Button
className="flex-1"
variant={daten.eigenerStand === "ZUGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ZUGESAGT" })}
>
Zusagen
</Button>
<Button
className="flex-1"
variant={daten.eigenerStand === "ABGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() => rueckmelden.mutate({ terminId: id, stand: "ABGESAGT" })}
>
Absagen
</Button>
</div>
{daten.freiePlaetze === 0 && daten.eigenerStand !== "ZUGESAGT" && (
<p className="text-sm text-[var(--muted-foreground)]">
Die Plätze sind belegt. Eine Zusage stellt dich auf die Warteliste — du
rückst nach, sobald jemand absagt.
</p>
)}
</>
)}
</CardContent>
</Card>
</div>
<div className="grid gap-6 md:grid-cols-3">
<Teilnehmerliste titel={`Zugesagt (${zugesagt.length})`} eintraege={zugesagt} />
{warteliste.length > 0 && (
<Teilnehmerliste
titel={`Warteliste (${warteliste.length})`}
eintraege={warteliste}
nummeriert
/>
)}
<Teilnehmerliste titel={`Abgesagt (${abgemeldet.length})`} eintraege={abgemeldet} mitGrund />
</div>
{darfBearbeiten && (
<AbsageDialog
offen={absageOffen}
terminId={id}
inSerie={Boolean(daten.serieId)}
schliessen={() => setAbsageOffen(false)}
fertig={() => {
setAbsageOffen(false);
void werkzeuge.termin.invalidate();
router.refresh();
}}
/>
)}
<AnwesenheitDialog
offen={listeOffen}
terminId={id}
darfAendern={darfAnwesenheit}
schliessen={() => setListeOffen(false)}
/>
</div>
);
}
function Feld({ titel, wert }: { titel: string; wert: string | null }) {
return (
<div>
<dt className="text-[var(--muted-foreground)]">{titel}</dt>
<dd className="font-medium">{wert ?? "—"}</dd>
</div>
);
}
function Teilnehmerliste({
titel,
eintraege,
nummeriert = false,
mitGrund = false,
}: {
titel: string;
eintraege: Array<{
id: string;
grund: string | null;
person: { vorname: string; nachname: string };
}>;
nummeriert?: boolean;
mitGrund?: boolean;
}) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{eintraege.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Niemand.</p>
) : (
<ol className="space-y-1 text-sm">
{eintraege.map((eintrag, stelle) => (
<li key={eintrag.id}>
{nummeriert && (
<span className="text-[var(--muted-foreground)]">{stelle + 1}. </span>
)}
{eintrag.person.nachname}, {eintrag.person.vorname}
{mitGrund && eintrag.grund && (
<span className="text-[var(--muted-foreground)]"> — {eintrag.grund}</span>
)}
</li>
))}
</ol>
)}
</CardContent>
</Card>
);
}
/** Absage mit Grund — und der Frage, ob die ganze Serie gemeint ist. */
function AbsageDialog({
offen,
terminId,
inSerie,
schliessen,
fertig,
}: {
offen: boolean;
terminId: string;
inSerie: boolean;
schliessen: () => void;
fertig: () => void;
}) {
const [grund, setGrund] = React.useState("");
const [ganzeSerie, setGanzeSerie] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const absagen = trpc.termin.absagen.useMutation({
onSuccess: fertig,
onError: (fehler) => setMeldung(fehler.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Termin absagen</DialogTitle>
<DialogDescription>
Der Termin bleibt im Kalender stehen — durchgestrichen, mit dem Grund. Wer zugesagt
hat, wird benachrichtigt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="absage-grund">Grund</Label>
<Textarea
id="absage-grund"
rows={3}
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Sturmwarnung, Übungsgelände nicht befahrbar."
/>
</div>
{inSerie && (
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={ganzeSerie}
onCheckedChange={(wert) => setGanzeSerie(wert === true)}
/>
<span>
Alle folgenden Termine dieser Serie ebenfalls absagen.
<span className="block text-[var(--muted-foreground)]">
Vergangene bleiben unberührt.
</span>
</span>
</label>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => absagen.mutate({ id: terminId, grund, ganzeSerie })}
disabled={!grund.trim() || absagen.isPending}
>
{absagen.isPending ? "Wird abgesagt …" : "Absagen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Die Anwesenheitsliste.
*
* Zeigt **alle aktiven Mitglieder**, nicht nur die Angemeldeten — auf einem
* Blatt zum Abhaken muss jeder stehen. Wer darf, hakt direkt hier ab; wer
* nicht darf, druckt sie aus und hakt auf Papier ab. Beides kommt vor, und
* beides ist in Ordnung.
*/
function AnwesenheitDialog({
offen,
terminId,
darfAendern,
schliessen,
}: {
offen: boolean;
terminId: string;
darfAendern: boolean;
schliessen: () => void;
}) {
const werkzeuge = trpc.useUtils();
const [meldung, setMeldung] = React.useState<string | null>(null);
const [aenderungen, setAenderungen] = React.useState<Record<string, boolean>>({});
const liste = trpc.termin.anwesenheitsliste.useQuery({ id: terminId }, { enabled: offen });
const speichern = trpc.termin.anwesenheit.useMutation({
onSuccess: () => {
setAenderungen({});
void werkzeuge.termin.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const zeilen = liste.data?.zeilen ?? [];
function stand(personId: string, gespeichert: boolean | null): boolean {
return aenderungen[personId] ?? gespeichert ?? false;
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Anwesenheitsliste</DialogTitle>
<DialogDescription>
{liste.data?.termin.titel} — {liste.data && alsDatum(liste.data.termin.beginn)}
</DialogDescription>
</DialogHeader>
{liste.isLoading && <p className="text-sm">Wird geladen …</p>}
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="py-2">Name</th>
<th className="py-2">Gemeldet</th>
<th className="py-2 text-right">Anwesend</th>
</tr>
</thead>
<tbody>
{zeilen.map((zeile) => (
<tr key={zeile.id} className="border-b border-[var(--border)]">
<td className="py-2">
{zeile.nachname}, {zeile.vorname}
</td>
<td className="py-2 text-[var(--muted-foreground)]">
{zeile.stand ? TEILNAHME_TEXT[zeile.stand] : "—"}
{zeile.grund && ` (${zeile.grund})`}
</td>
<td className="py-2 text-right">
{darfAendern ? (
<Checkbox
checked={stand(zeile.id, zeile.anwesend)}
onCheckedChange={(wert) =>
setAenderungen((bisher) => ({ ...bisher, [zeile.id]: wert === true }))
}
aria-label={`Anwesend: ${zeile.vorname} ${zeile.nachname}`}
/>
) : (
// Ohne Recht zum Abhaken: ein Kästchen zum Ausdrucken.
<span className="inline-block size-4 rounded border border-[var(--border)]" />
)}
</td>
</tr>
))}
</tbody>
</table>
{meldung && <Fehlermeldung meldung={meldung} />}
<DialogFooter className="nicht-drucken">
<Button variant="outline" onClick={() => window.print()}>
<Printer aria-hidden />
Drucken
</Button>
{darfAendern && (
<Button
disabled={Object.keys(aenderungen).length === 0 || speichern.isPending}
onClick={() =>
speichern.mutate({
terminId,
eintraege: Object.entries(aenderungen).map(([personId, anwesend]) => ({
personId,
anwesend,
})),
})
}
>
{speichern.isPending ? "Wird gespeichert …" : "Anwesenheit speichern"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { TerminListe } from "@/app/(app)/termine/termin-liste";
export const metadata = { title: "Termine" };
/**
* Der Terminkalender der Wehr.
*
* Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen — was auf dem
* Jahresplan steht, steht hier, und wer will, hat es im Kalender seines
* Handys.
*
* Die Ansicht zeigt standardmäßig nach vorn. Wer zurückschaut, tut das für
* den Nachweis, und dafür gibt es die Anwesenheitsliste am einzelnen Termin.
*/
export default async function TermineSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Termine</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Dienste, Übungen und Veranstaltungen — mit Rückmeldung und Anwesenheit.
</p>
</div>
<TerminListe darfBearbeiten={hatRecht(sitzung, "termin.bearbeiten")} />
</div>
);
}
+472
View File
@@ -0,0 +1,472 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { CalendarPlus, Check, Repeat, X } from "lucide-react";
import { Serienmuster, TerminArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { MUSTER_TEXT } from "@/lib/termin/serie";
import { TERMINART_TEXT, nochHin } from "@/lib/termin/texte";
import { alsDatum, alsUhrzeit } from "@/lib/utils";
/**
* Die Terminliste.
*
* Bewusst keine Monatsansicht mit Kästchen: eine Wehr hat im Monat drei bis
* acht Termine, und die will man untereinander lesen — mit Datum, Uhrzeit,
* Ort und dem eigenen Stand. Ein Kalenderraster verbraucht den Platz für
* neunundzwanzig leere Tage.
*
* Wer das Raster will, hat es ohnehin schon: im Handy, über das Abonnement.
*/
export function TerminListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [art, setArt] = React.useState<TerminArt | "alle">("alle");
const [nurEigene, setNurEigene] = React.useState(false);
const [offen, setOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const termine = trpc.termin.liste.useQuery({
art: art === "alle" ? undefined : art,
nurEigene,
anzahl: 200,
});
const rueckmelden = trpc.termin.rueckmelden.useMutation({
onSuccess: () => void werkzeuge.termin.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = termine.data ?? [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={art} onValueChange={(wert) => setArt(wert as TerminArt | "alle")}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Alle Arten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Arten</SelectItem>
{Object.values(TerminArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{TERMINART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={nurEigene}
onCheckedChange={(wert) => setNurEigene(wert === true)}
aria-label="Nur Termine mit eigener Rückmeldung"
/>
Nur meine
</label>
<div className="ml-auto flex gap-2">
<Button variant="outline" asChild>
<Link href="/benachrichtigungen">Kalender abonnieren</Link>
</Button>
{darfBearbeiten && (
<Button onClick={() => setOffen(true)}>
<CalendarPlus aria-hidden />
Termin anlegen
</Button>
)}
</div>
</div>
{meldung && <Fehlermeldung meldung={meldung} kennung="terminfehler" />}
{termine.error && <Fehlermeldung meldung={termine.error.message} kennung="terminfehler" />}
{termine.isLoading && (
// Keine LadeZeilen aus den Stammdaten: die rendern <tr> und gehören in
// eine Tabelle. Hier sind es Karten.
<ul className="space-y-3" aria-hidden>
{Array.from({ length: 5 }).map((_, zeile) => (
<li
key={zeile}
className="h-24 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
/>
))}
</ul>
)}
{!termine.isLoading && eintraege.length === 0 && (
<NichtsGefunden
titel="Keine Termine"
hinweis={
nurEigene
? "Zu keinem kommenden Termin liegt eine Rückmeldung von dir vor."
: "Für die kommenden Monate ist nichts eingetragen."
}
/>
)}
<ul className="space-y-3">
{eintraege.map((termin) => {
const abgesagt = Boolean(termin.abgesagtAm);
const voll =
termin.teilnehmerLimit !== null && termin.zusagen >= termin.teilnehmerLimit;
return (
<li
key={termin.id}
className={`rounded-lg border border-[var(--border)] bg-[var(--card)] p-4 ${
abgesagt ? "opacity-60" : ""
}`}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Link
href={`/termine/${termin.id}`}
className="font-medium underline-offset-4 hover:underline"
>
{termin.titel}
</Link>
<Badge variant="outline">{TERMINART_TEXT[termin.art]}</Badge>
{termin.serieId && (
<Badge variant="secondary" title={MUSTER_TEXT[termin.serienmuster]}>
<Repeat className="mr-1 size-3" aria-hidden />
Serie
</Badge>
)}
{abgesagt && <Badge variant="gesperrt">Abgesagt</Badge>}
</div>
<p className="text-sm text-[var(--muted-foreground)]">
{alsDatum(termin.beginn)}
{!termin.ganztaegig && `, ${alsUhrzeit(termin.beginn)} Uhr`}
{termin.ort && ` · ${termin.ort}`}
{" · "}
{nochHin(termin.beginn)}
</p>
{abgesagt && termin.absageGrund && (
<p className="text-sm text-[var(--gesperrt)]">Grund: {termin.absageGrund}</p>
)}
{termin.anmeldungNoetig && !abgesagt && (
<p className="text-sm text-[var(--muted-foreground)]">
{termin.zusagen} zugesagt
{termin.teilnehmerLimit !== null &&
` von ${termin.teilnehmerLimit} Plätzen`}
{termin.anmeldeschluss &&
` · Anmeldeschluss ${alsDatum(termin.anmeldeschluss)}`}
</p>
)}
</div>
{!abgesagt && (
<div className="flex shrink-0 gap-2">
<Button
size="sm"
variant={termin.eigenerStand === "ZUGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() =>
rueckmelden.mutate({ terminId: termin.id, stand: "ZUGESAGT" })
}
// Ein volles Kontingent hindert nicht am Zusagen — es
// führt auf die Warteliste. Das steht in der Antwort.
title={voll ? "Die Plätze sind belegt — du kommst auf die Warteliste." : undefined}
>
<Check aria-hidden />
{termin.eigenerStand === "WARTELISTE" ? "Warteliste" : "Zusagen"}
</Button>
<Button
size="sm"
variant={termin.eigenerStand === "ABGESAGT" ? "default" : "outline"}
disabled={rueckmelden.isPending}
onClick={() =>
rueckmelden.mutate({ terminId: termin.id, stand: "ABGESAGT" })
}
>
<X aria-hidden />
Absagen
</Button>
</div>
)}
</div>
</li>
);
})}
</ul>
{darfBearbeiten && (
<TerminAnlegen
offen={offen}
schliessen={() => setOffen(false)}
fertig={(id) => {
setOffen(false);
void werkzeuge.termin.invalidate();
router.push(`/termine/${id}`);
}}
/>
)}
</div>
);
}
/** Datum von heute im Format, das ein datetime-local-Feld erwartet. */
function naechsterAbend(): string {
const wann = new Date();
wann.setDate(wann.getDate() + 7);
wann.setHours(19, 0, 0, 0);
return zurEingabe(wann);
}
function zurEingabe(wert: Date): string {
const versatz = wert.getTimezoneOffset() * 60_000;
return new Date(wert.getTime() - versatz).toISOString().slice(0, 16);
}
/**
* Das Anlegeformular.
*
* Der wichtigste Teil ist die Zeile ganz unten: bei einer Serie steht dort,
* wie viele Termine daraus werden. Wer „wöchentlich bis 31.12." einstellt,
* soll vorher lesen, dass das siebzehn Einträge sind — nicht danach.
*/
function TerminAnlegen({
offen,
schliessen,
fertig,
}: {
offen: boolean;
schliessen: () => void;
fertig: (id: string) => void;
}) {
const [titel, setTitel] = React.useState("");
const [art, setArt] = React.useState<TerminArt>(TerminArt.DIENST);
const [beginn, setBeginn] = React.useState(naechsterAbend());
const [ende, setEnde] = React.useState("");
const [ort, setOrt] = React.useState("");
const [beschreibung, setBeschreibung] = React.useState("");
const [muster, setMuster] = React.useState<Serienmuster>(Serienmuster.KEINE);
const [serieBis, setSerieBis] = React.useState("");
const [anmeldungNoetig, setAnmeldungNoetig] = React.useState(false);
const [limit, setLimit] = React.useState("");
const [meldung, setMeldung] = React.useState<string | null>(null);
const serie = muster !== Serienmuster.KEINE;
const vorschau = trpc.termin.serienvorschau.useQuery(
{ beginn: new Date(beginn), muster, serieBis: new Date(serieBis || beginn) },
{ enabled: offen && serie && Boolean(beginn) && Boolean(serieBis) },
);
const anlegen = trpc.termin.anlegen.useMutation({
onSuccess: (ergebnis) => fertig(ergebnis.id),
onError: (fehler) => setMeldung(fehler.message),
});
function speichern() {
setMeldung(null);
anlegen.mutate({
titel,
art,
beginn: new Date(beginn),
ende: ende ? new Date(ende) : null,
ort: ort || null,
beschreibung: beschreibung || null,
ganztaegig: false,
anmeldungNoetig,
teilnehmerLimit: anmeldungNoetig && limit ? Number(limit) : null,
serienmuster: muster,
serieBis: serie && serieBis ? new Date(serieBis) : null,
});
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Termin anlegen</DialogTitle>
<DialogDescription>
Ein einzelner Termin oder eine Serie. Anmeldung ist die Ausnahme — der
Übungsabend braucht keine.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="termin-titel">Titel</Label>
<Input
id="termin-titel"
value={titel}
onChange={(ereignis) => setTitel(ereignis.target.value)}
placeholder="Übungsabend Löschangriff"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="termin-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as TerminArt)}>
<SelectTrigger id="termin-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(TerminArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{TERMINART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="termin-ort">Ort</Label>
<Input
id="termin-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
placeholder="Gerätehaus"
/>
</div>
<div className="space-y-2">
<Label htmlFor="termin-beginn">Beginn</Label>
<Input
id="termin-beginn"
type="datetime-local"
value={beginn}
onChange={(ereignis) => setBeginn(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="termin-ende">Ende (freiwillig)</Label>
<Input
id="termin-ende"
type="datetime-local"
value={ende}
onChange={(ereignis) => setEnde(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="termin-beschreibung">Beschreibung</Label>
<Textarea
id="termin-beschreibung"
rows={3}
value={beschreibung}
onChange={(ereignis) => setBeschreibung(ereignis.target.value)}
placeholder="Was gemacht wird, was mitzubringen ist."
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="termin-muster">Wiederholung</Label>
<Select value={muster} onValueChange={(wert) => setMuster(wert as Serienmuster)}>
<SelectTrigger id="termin-muster">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(Serienmuster).map((wert) => (
<SelectItem key={wert} value={wert}>
{MUSTER_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{serie && (
<div className="space-y-2">
<Label htmlFor="termin-serie-bis">Serie läuft bis</Label>
<Input
id="termin-serie-bis"
type="date"
value={serieBis}
onChange={(ereignis) => setSerieBis(ereignis.target.value)}
/>
</div>
)}
</div>
{serie && vorschau.data && (
<p className="rounded-md bg-[var(--muted)] px-3 py-2 text-sm">
Das legt <strong>{vorschau.data.anzahl} Termine</strong> an, vom{" "}
{vorschau.data.erster ? alsDatum(vorschau.data.erster) : "—"} bis zum{" "}
{vorschau.data.letzter ? alsDatum(vorschau.data.letzter) : "—"}.
{vorschau.data.gekappt && " Mehr als das legt FLORIAN nicht auf einmal an."}
</p>
)}
<div className="space-y-3 border-t border-[var(--border)] pt-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={anmeldungNoetig}
onCheckedChange={(wert) => setAnmeldungNoetig(wert === true)}
/>
Rückmeldung verlangen
</label>
{anmeldungNoetig && (
<div className="space-y-2">
<Label htmlFor="termin-limit">Plätze (leer = unbegrenzt)</Label>
<Input
id="termin-limit"
type="number"
min={1}
value={limit}
onChange={(ereignis) => setLimit(ereignis.target.value)}
className="w-32"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Ist die Zahl erreicht, kommen weitere Zusagen auf die Warteliste und rücken
bei einer Absage von selbst nach.
</p>
</div>
)}
</div>
{meldung && <Fehlermeldung meldung={meldung} />}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button onClick={speichern} disabled={!titel.trim() || anlegen.isPending}>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}