Files
FireAdministration/src/app/(app)/mitglieder/page.tsx
T
Florian WildeandClaude Opus 5 0b8874da26 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>
2026-09-04 08:39:38 +02:00

154 lines
5.8 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personName } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} 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" };
/**
* Mitgliederliste, nach Abteilung gruppiert.
*
* Aktive, Jugendfeuerwehr und Passive werden getrennt geführt — eine
* durchlaufende alphabetische Liste vermischt Gruppen, die in der Wehr nichts
* miteinander zu tun haben.
*/
export default async function MitgliederSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "person.lesen")) redirect("/");
const personen = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findMany({
where: { geloeschtAm: null },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
mobil: true,
eintrittsdatum: true,
aktiv: true,
rollen: { select: { rolle: { select: { name: true, schluessel: true } } } },
_count: { select: { qualifikationen: true } },
},
}),
);
const nachAbteilung = new Map<string, typeof personen>();
for (const person of personen) {
const gruppe = person.abteilung ?? "Ohne Abteilung";
if (!nachAbteilung.has(gruppe)) nachAbteilung.set(gruppe, []);
nachAbteilung.get(gruppe)!.push(person);
}
const reihenfolge = ["Aktive", "Jugendfeuerwehr", "Passive", "Verwaltung", "Ohne Abteilung"];
const gruppen = [...nachAbteilung.entries()].sort((a, b) => {
const ia = reihenfolge.indexOf(a[0]);
const ib = reihenfolge.indexOf(b[0]);
if (ia === -1 && ib === -1) return a[0].localeCompare(b[0], "de");
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitglieder</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{personen.length} {personen.length === 1 ? "Mitglied" : "Mitglieder"}, nach
Abteilung geordnet.
</p>
</div>
{personen.length === 0 ? (
<NichtsGefunden
titel="Noch keine Mitglieder erfasst"
hinweis="Mitglieder lassen sich einzeln anlegen oder aus einer vorhandenen Liste importieren."
/>
) : (
gruppen.map(([abteilung, liste]) => (
<section key={abteilung} className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{abteilung} ({liste.length})
</h2>
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Nr.</TableHead>
<TableHead>Dienstgrad</TableHead>
<TableHead>Funktion</TableHead>
<TableHead>Kontakt</TableHead>
<TableHead className="text-right">Eintritt</TableHead>
<TableHead className="text-right">Qualifikationen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{liste.map((person) => (
<TableRow key={person.id} className={!person.aktiv ? "opacity-60" : undefined}>
<TableCell className="font-medium">
<Link
href={`/mitglieder/${person.id}`}
className="underline-offset-4 hover:underline"
>
{personName(person)}
</Link>
</TableCell>
<TableCell className="tabellenzahlen text-[var(--muted-foreground)]">
{person.mitgliedsnummer ?? "—"}
</TableCell>
<TableCell className="text-sm">
{DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{person.rollen
.filter((zuweisung) => zuweisung.rolle.schluessel !== "MITGLIED")
.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{person.mobil ?? person.email ?? "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(person.eintrittsdatum)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person._count.qualifikationen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
))
)}
</div>
);
}