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>
@@ -255,6 +255,76 @@ TELEGRAM_ENABLED=false
|
|||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
TELEGRAM_CHAT_ID=
|
TELEGRAM_CHAT_ID=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Push über ntfy (Phase 7, optional)
|
||||||
|
#
|
||||||
|
# ntfy ist ein winziger Push-Dienst, den man selbst betreibt: ein Container,
|
||||||
|
# eine App im Store, fertig. Man abonniert ein "Thema" (einen frei gewählten
|
||||||
|
# Namen) und bekommt Meldungen darauf — ohne Konto, ohne Telefonnummer.
|
||||||
|
#
|
||||||
|
# Läuft der Server im eigenen Netz, verlässt keine Nachricht das Gerätehaus.
|
||||||
|
# Deshalb gilt ntfy in FLORIAN als INTERNER Kanal und bekommt die
|
||||||
|
# ausführlichen Texte. Wer stattdessen das öffentliche ntfy.sh einträgt, macht
|
||||||
|
# ihn zu einem externen Dienst wie jeden anderen.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest mit.
|
||||||
|
# FLORIAN schlägt deshalb Namen mit Zufallsanteil vor.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
NTFY_ENABLED=false
|
||||||
|
# NTFY_URL=http://ntfy:80
|
||||||
|
# Nur nötig, wenn der eigene ntfy Zugriffsschutz eingeschaltet hat.
|
||||||
|
NTFY_TOKEN=
|
||||||
|
NTFY_TIMEOUT=10
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Web-Push in den Browser (Phase 7, optional)
|
||||||
|
#
|
||||||
|
# Der Weg, der ohne App auskommt. Die Meldung läuft über den Push-Dienst des
|
||||||
|
# Browserherstellers (Mozilla, Google, Apple) — verschlüsselt, aber sie
|
||||||
|
# verlässt das Netz der Wehr. Deshalb ist Web-Push ein EXTERNER Kanal und
|
||||||
|
# bekommt nur Kurzfassungen: "3 Prüfungen fällig", nicht welche.
|
||||||
|
#
|
||||||
|
# Schlüsselpaar erzeugen mit: npm run vapid
|
||||||
|
#
|
||||||
|
# ACHTUNG: Beide Schlüssel gehören ins Backup. Ein neuer öffentlicher Schlüssel
|
||||||
|
# macht alle bestehenden Push-Abos wertlos — jeder müsste Push neu erlauben,
|
||||||
|
# und niemand merkt es, die Meldungen bleiben einfach aus.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
WEBPUSH_ENABLED=false
|
||||||
|
WEBPUSH_PUBLIC_KEY=
|
||||||
|
WEBPUSH_PRIVATE_KEY=
|
||||||
|
# Kontaktweg für den Push-Dienst, "mailto:..." oder eine Adresse.
|
||||||
|
WEBPUSH_KONTAKT=
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WhatsApp: bewusst NICHT.
|
||||||
|
#
|
||||||
|
# Es gibt keine WHATSAPP_-Variablen, und das ist eine Entscheidung, keine Lücke.
|
||||||
|
# Die Begründung steht im README unter "Warum kein WhatsApp" — kurz: die
|
||||||
|
# Business-Plattform kostet je Konversation, verlangt einen Vertrag mit Meta
|
||||||
|
# als Auftragsverarbeiter, und die App gleicht das Telefonbuch ab. Für eine
|
||||||
|
# Wehr, deren Daten im Gerätehaus bleiben sollen, ist das der falsche Weg.
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Benachrichtigungen und Termine (Phase 7)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Wann der Worker die Erinnerungen anlegt und verschickt (cron, Europe/Berlin).
|
||||||
|
# Bewusst am frühen Morgen und nicht nachts: eine Push-Meldung um drei Uhr
|
||||||
|
# weckt Leute, die am nächsten Tag arbeiten müssen.
|
||||||
|
BENACHRICHTIGUNG_LAUF=0 7 * * *
|
||||||
|
# Wie viele Tage vor einem Termin erinnert wird (kommagetrennt).
|
||||||
|
# "7,1": einmal in der Woche davor, damit man den Abend freihalten kann, und
|
||||||
|
# einmal am Vortag, weil man es bis dahin wieder vergessen hat.
|
||||||
|
TERMIN_ERINNERUNG_TAGE=7,1
|
||||||
|
# Wie oft eine fehlgeschlagene Zustellung wiederholt wird, bevor sie als
|
||||||
|
# fehlgeschlagen stehen bleibt. Stehen bleiben ist Absicht: eine still
|
||||||
|
# verschluckte Benachrichtigung ist schlimmer als eine sichtbar gescheiterte.
|
||||||
|
BENACHRICHTIGUNG_VERSUCHE=3
|
||||||
|
# Wie lange ZUGESTELLTE Benachrichtigungen aufgehoben werden.
|
||||||
|
# Fehlgeschlagene bleiben — sie zeigen einen kaputten Kanal an.
|
||||||
|
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE=90
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
# LLM-Anbindung (optional; nur selbstgehostete Endpunkte, kein Cloud-Zwang)
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -8,7 +8,171 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
|||||||
|
|
||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
Nächster Schritt ist Phase 7 (Benachrichtigungen und Termine).
|
Nächster Schritt ist Phase 8 (Lokale KI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.8.0] — 2026-09-04
|
||||||
|
|
||||||
|
Phase 7: Benachrichtigungen und Termine. Ab hier weiß die Mannschaft, wann der
|
||||||
|
nächste Dienst ist — ohne dass jemand eine Rundmail tippt.
|
||||||
|
|
||||||
|
**Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt, bekommt nichts.
|
||||||
|
Es gibt keine Voreinstellung, die stillschweigend alle anmeldet: ein neu
|
||||||
|
eingerichteter Zustellweg hat eine leere Ereignisliste, und erst wer ankreuzt,
|
||||||
|
wofür er ihn will, bekommt etwas.
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
|
||||||
|
**Datenmodell**
|
||||||
|
|
||||||
|
- `Termin` mit Art, Ort, Beginn und Ende, Leitung, Platzbegrenzung,
|
||||||
|
Anmeldeschluss und Serienkennung. Abgesagt wird mit Grund und Zeitpunkt —
|
||||||
|
**gelöscht wird nicht**, wer zugesagt hat, soll den Grund sehen.
|
||||||
|
- `Terminanmeldung` mit Zu-, Absage oder Warteliste, dem Grund einer Absage und
|
||||||
|
der Anwesenheit als eigenem Feld. Zusage und Anwesenheit sind bewusst
|
||||||
|
getrennt: wer zugesagt hat und nicht kam, steht genau so in der Liste.
|
||||||
|
- `Zustellkanal` — ein Datensatz je Person und Weg, mit der Adresse dieses Wegs
|
||||||
|
und der Liste der Ereignisse, für die er benutzt wird. Leer bedeutet: für
|
||||||
|
nichts.
|
||||||
|
- `Benachrichtigung` — steht **vor** dem Versand in der Tabelle, nicht danach.
|
||||||
|
Damit ist nachvollziehbar, wer wann worüber informiert wurde und wer nicht,
|
||||||
|
weil sein Kanal nicht erreichbar war.
|
||||||
|
- `Benachrichtigungsvorlage` je Ereignis und Kanal, `Kalenderzugang` mit
|
||||||
|
SHA-256 des Tokens.
|
||||||
|
|
||||||
|
**Terminkalender**
|
||||||
|
|
||||||
|
- Liste mit Filter nach Art und „nur meine", Rückmeldung direkt aus der Zeile.
|
||||||
|
- Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
|
||||||
|
Abend ist ein eigener Datensatz. Sonst wäre jeder ausgefallene Übungsabend
|
||||||
|
ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele Termine daraus
|
||||||
|
werden.
|
||||||
|
- Kalenderrechnung statt Millisekunden: „monatlich" heißt derselbe Tag im
|
||||||
|
nächsten Monat, und der 31. Januar landet im Februar auf dem 28. — nicht im
|
||||||
|
März, wie es passiert, wenn man stur Monate addiert.
|
||||||
|
- **Warteliste, die von selbst nachrückt.** Wer bei belegten Plätzen zusagt,
|
||||||
|
landet auf ihr; sagt jemand ab, rückt der Erste nach und erfährt es.
|
||||||
|
- Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
|
||||||
|
Vergangene bleiben unberührt. Benachrichtigt werden die Angemeldeten, nicht
|
||||||
|
die ganze Wehr.
|
||||||
|
- Anwesenheitsliste über **alle aktiven Mitglieder**, nicht nur die
|
||||||
|
Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Zum Abhaken am
|
||||||
|
Tablet oder zum Ausdrucken.
|
||||||
|
|
||||||
|
**Kalenderabonnement**
|
||||||
|
|
||||||
|
- ICS-Feed nach RFC 5545 unter `/api/kalender/<token>.ics`. Die Termine der
|
||||||
|
Wehr stehen damit im Handy neben allem anderen.
|
||||||
|
- **Bewusst kein CalDAV**: das ist ein Schreib-Lese-Protokoll mit
|
||||||
|
Rechteverwaltung und Konfliktauflösung. Gebraucht wird nur Lesen.
|
||||||
|
- Die Adresse steht genau einmal auf dem Bildschirm; gespeichert wird nur ihr
|
||||||
|
Hash. Jeder Zugang lässt sich einzeln widerrufen, und wer austritt, verliert
|
||||||
|
ihn.
|
||||||
|
- Ausgeliefert werden nur Termine — keine Anmeldelisten, keine Personen außer
|
||||||
|
der Leitung.
|
||||||
|
|
||||||
|
**Vier Zustellwege, hinter Schnittstellen**
|
||||||
|
|
||||||
|
- **SMTP** löst den Platzhalter aus Phase 0 ab: nodemailer mit
|
||||||
|
Verbindungspool, damit ein Sammellauf mit vierzig Nachrichten Sekunden statt
|
||||||
|
Minuten braucht.
|
||||||
|
- **ntfy** (neu, selbst gehostet, Compose-Profil `push`): Push aufs Handy ohne
|
||||||
|
Konto, ohne Telefonnummer, ohne Telefonbuchabgleich. Läuft der Server im
|
||||||
|
Gerätehaus, verlässt keine Nachricht das Haus.
|
||||||
|
- **Web-Push** (neu, VAPID): Push in den Browser ohne App. Schlüsselpaar über
|
||||||
|
`npm run vapid`.
|
||||||
|
- **Telegram** aus Phase 0 wird jetzt tatsächlich benutzt.
|
||||||
|
- Was das Netz der Wehr verlässt (Telegram, Web-Push), bekommt **nur die
|
||||||
|
Kurzfassung** — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht
|
||||||
|
das neben jedem externen Kanal.
|
||||||
|
|
||||||
|
**Textvorlagen**
|
||||||
|
|
||||||
|
- Je Ereignis und Kanal, mit eingebautem Standardtext für alles. Eine Wehr, die
|
||||||
|
nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
|
||||||
|
- Platzhalter zum Anklicken, Vorschau mit erfundenen Beispielwerten. Ein
|
||||||
|
Platzhalter, den es für das Ereignis nicht gibt, wird beim Speichern
|
||||||
|
abgewiesen — mit der Liste der erlaubten daneben.
|
||||||
|
- Ein unbekannter Platzhalter bleibt im Text **sichtbar stehen**, statt still
|
||||||
|
zu verschwinden. Eine Lücke, die niemand bemerkt, ist schlimmer als ein
|
||||||
|
seltsam aussehender Text, den jemand korrigiert.
|
||||||
|
|
||||||
|
**Digitaler Dienstausweis**
|
||||||
|
|
||||||
|
- QR-Code auf dem Handy, Prüfseite ohne Anmeldung unter `/ausweis/<code>`.
|
||||||
|
Sie zeigt Name, Wehr, Funktion, Foto und die gültigen Qualifikationen.
|
||||||
|
- Signiert statt gespeichert (HMAC über Person, Wehr und Zeitstempel), **fünf
|
||||||
|
Minuten gültig**. Die Prüfseite nennt den Zeitpunkt der Ausstellung — ein
|
||||||
|
Ausweis, der „gültig" sagt, ohne zu sagen seit wann, ist wertlos.
|
||||||
|
- Der Zweck steht im signierten Teil, damit ein Ausweis nicht als Anmeldemarke
|
||||||
|
aus Phase 4 durchgeht und umgekehrt.
|
||||||
|
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird
|
||||||
|
abgewiesen. Das Passbild ist über denselben Code erreichbar und damit genauso
|
||||||
|
lange gültig.
|
||||||
|
|
||||||
|
**Was von selbst benachrichtigt**
|
||||||
|
|
||||||
|
- Prüfung fällig, Mangel gemeldet, Qualifikation läuft ab (90/30/7 Tage
|
||||||
|
vorher), neuer Termin, Terminerinnerung (7 und 1 Tag vorher), Einsatzbericht
|
||||||
|
freigegeben.
|
||||||
|
- **Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur
|
||||||
|
eine Zeile geschrieben; der Worker schickt sie weg. Sonst hinge das Speichern
|
||||||
|
eines Termins am Antwortverhalten von Telegram.
|
||||||
|
- Erinnert wird um sieben Uhr morgens, zugestellt jede Minute: eine
|
||||||
|
Push-Meldung um drei Uhr weckt Leute, die am nächsten Tag arbeiten müssen —
|
||||||
|
eine Absage am Dienstagabend muss dagegen ankommen, solange sie noch etwas
|
||||||
|
nützt.
|
||||||
|
- Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war. Wer sie
|
||||||
|
ohnehin bekommt, wird von der zusätzlichen Benachrichtigung ausgenommen und
|
||||||
|
hat sie damit nicht doppelt.
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
|
||||||
|
- `lib/integrations/mail.ts` verschickt tatsächlich, statt eine Meldung ins Log
|
||||||
|
zu schreiben. Ohne `MAIL_ENABLED` bleibt es beim Log.
|
||||||
|
- Neue Rechte: `termin.lesen` und `benachrichtigung.eigene` als Grundrechte
|
||||||
|
(jeder sieht den Kalender und richtet seine eigenen Wege ein — das ist keine
|
||||||
|
Sache des Kommandanten), `termin.bearbeiten`, `termin.anwesenheit` und
|
||||||
|
`benachrichtigung.vorlagen` bei Kommandant, Schriftführer und Ausbilder.
|
||||||
|
- `DIENSTGRAD_TEXT` liegt jetzt in `lib/stammdaten/texte.ts` statt zweimal fast
|
||||||
|
gleich in zwei Seiten.
|
||||||
|
- Der Service Worker nimmt Push-Meldungen an und öffnet beim Antippen ein schon
|
||||||
|
offenes FLORIAN, statt einen zwölften Tab aufzumachen.
|
||||||
|
- Der Aufbewahrungslauf räumt zugestellte Benachrichtigungen nach Frist ab.
|
||||||
|
**Fehlgeschlagene bleiben** — sie zeigen einen kaputten Kanal an, und das
|
||||||
|
verliert seinen Wert nicht durch Zeitablauf.
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
|
||||||
|
- Die Vorlagenmaske überschrieb den gerade getippten Text, sobald die Liste im
|
||||||
|
Hintergrund nachgeladen wurde (nach dem Speichern, beim Zurückkommen ins
|
||||||
|
Fenster). Gefunden hat es der E2E-Test, nicht das Ausprobieren.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
- Row Level Security und Mandantenfilter für alle sechs neuen Tabellen.
|
||||||
|
- `REVOKE DELETE ON "benachrichtigung"` für die Anwendungsrolle: wer wann
|
||||||
|
worüber informiert wurde, lässt sich nicht über die Oberfläche wegräumen.
|
||||||
|
Die Aufbewahrungsfrist läuft über den Systemzugang.
|
||||||
|
- Prüfbedingungen in der Datenbank: kein Ende vor dem Beginn, kein
|
||||||
|
Anmeldeschluss nach dem Termin, kein Kontingent von null Plätzen, keine
|
||||||
|
negative Versuchszahl.
|
||||||
|
- Kalendertoken nur als SHA-256; ein Push-Abo, das der Dienst als tot meldet,
|
||||||
|
wird stillgelegt statt gelöscht, damit die Person es in ihren Einstellungen
|
||||||
|
sieht und neu erlauben kann.
|
||||||
|
- **Kein WhatsApp** — Begründung im README unter „Warum kein WhatsApp":
|
||||||
|
Verarbeitung über Meta-Server, Kosten je Konversation, Telefonbuchabgleich.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
- 56 neue Unit-Tests: Serienrechnung (Monatsenden, Schaltjahr, Kappung),
|
||||||
|
ICS-Erzeugung (Maskierung, Zeilenumbruch nach 75 Oktetten, CRLF, Absage),
|
||||||
|
Textbausteine (Kurzfassung für externe Kanäle, unbekannte Platzhalter),
|
||||||
|
Dienstausweis (Frist, Fälschung, Verwechslung mit der Anmeldemarke) und die
|
||||||
|
Datenbankregeln der neuen Tabellen.
|
||||||
|
- 17 neue E2E-Tests für Kalender, Rückmeldung, Warteliste, Absage,
|
||||||
|
Anwesenheitsliste, Kalenderabonnement, Vorlagen und Prüfseite.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
| Phase 4 — Kiosk-PWA im Gerätehaus | fertig |
|
||||||
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
| Phase 5 — Atemschutz (FwDV 7) | fertig |
|
||||||
| Phase 6 — Kartenmodule | fertig |
|
| Phase 6 — Kartenmodule | fertig |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | offen |
|
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||||
| Phase 8 — Lokale KI | offen |
|
| Phase 8 — Lokale KI | offen |
|
||||||
| Phase 9 — Kassenverwaltung | offen |
|
| Phase 9 — Kassenverwaltung | offen |
|
||||||
|
|
||||||
@@ -785,6 +785,194 @@ Verteiler.**
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
|
Der Satz, der über der ganzen Phase steht und der auch die Technik darunter
|
||||||
|
bestimmt: **Niemand wird ungefragt benachrichtigt.** Wer nichts einträgt,
|
||||||
|
bekommt nichts. Es gibt keine Voreinstellung, die stillschweigend alle
|
||||||
|
anmeldet — ein neu eingerichteter Zustellweg hat eine leere Ereignisliste, und
|
||||||
|
erst wer ankreuzt, wofür er ihn will, bekommt etwas.
|
||||||
|
|
||||||
|
### Der Terminkalender
|
||||||
|
|
||||||
|
Übungsabende, Lehrgänge, Arbeitsdienste, Sitzungen. Drei Entscheidungen prägen
|
||||||
|
ihn, und alle drei kommen aus dem Alltag einer Wehr:
|
||||||
|
|
||||||
|
**Anmeldung ist die Ausnahme, nicht die Regel.** Beim normalen Übungsabend
|
||||||
|
meldet sich niemand an, man kommt oder man kommt nicht. Erst beim Lehrgang mit
|
||||||
|
zwölf Plätzen und beim Ausflug mit Busbestellung wird eine Rückmeldung
|
||||||
|
verlangt.
|
||||||
|
|
||||||
|
**Abgesagt, nicht gelöscht.** Ein Termin, der ohne ein Wort verschwindet, führt
|
||||||
|
dazu, dass am Dienstagabend jemand vor dem verschlossenen Gerätehaus steht.
|
||||||
|
Eine Absage braucht einen Grund, bleibt im Kalender stehen und erreicht die,
|
||||||
|
die zugesagt hatten — nicht die ganze Wehr.
|
||||||
|
|
||||||
|
**Die Warteliste rückt von selbst nach.** Wer bei belegten Plätzen zusagt,
|
||||||
|
landet auf der Warteliste; sagt jemand ab, rückt der Erste nach und erfährt es.
|
||||||
|
Sonst führt jemand die Liste von Hand, und das tut niemand zuverlässig.
|
||||||
|
|
||||||
|
Serientermine werden **ausgeschrieben**, nicht als Regel gespeichert: jeder
|
||||||
|
Abend ist ein eigener Datensatz. Der Grund ist derselbe Alltag — der Übungsabend
|
||||||
|
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||||||
|
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Mit einer Regel wäre jede
|
||||||
|
dieser Ausnahmen ein Sonderfall. Vor dem Anlegen steht in der Maske, wie viele
|
||||||
|
Termine daraus werden.
|
||||||
|
|
||||||
|
Die Anwesenheitsliste zeigt **alle aktiven Mitglieder**, nicht nur die
|
||||||
|
Angemeldeten: auf einem Blatt zum Abhaken muss jeder stehen. Zusage und
|
||||||
|
Anwesenheit sind getrennt — wer zugesagt hat und nicht kam, steht genau so in
|
||||||
|
der Liste.
|
||||||
|
|
||||||
|
### Der Kalender fürs Handy
|
||||||
|
|
||||||
|
Eine Adresse, die man im Handy oder im Mailprogramm abonniert; die Termine
|
||||||
|
stehen dann neben allem anderen, ohne dass jemand FLORIAN öffnen muss. Das ist
|
||||||
|
der Kanal mit der größten Wirkung pro Zeile Code in dieser Phase.
|
||||||
|
|
||||||
|
**Bewusst kein CalDAV.** Das ist ein Schreib-Lese-Protokoll mit
|
||||||
|
Rechteverwaltung und Konfliktauflösung; gebraucht wird hier nur Lesen, und dafür
|
||||||
|
genügt eine Adresse, die eine ICS-Datei ausliefert. Jeder Kalender kann das.
|
||||||
|
|
||||||
|
Die Adresse ist der Schlüssel — Kalenderprogramme können sich nicht anmelden,
|
||||||
|
sie rufen stumpf eine URL ab. Daraus folgt:
|
||||||
|
|
||||||
|
- Gespeichert wird nur der SHA-256 des Tokens; die Adresse steht **genau
|
||||||
|
einmal** auf dem Bildschirm.
|
||||||
|
- Jeder Zugang lässt sich einzeln widerrufen. Ein verlorenes Handy kostet einen
|
||||||
|
Klick, nicht ein neues Passwort für alle.
|
||||||
|
- Ausgeliefert werden **nur Termine** — keine Anmeldelisten, keine Personen
|
||||||
|
außer der Leitung, keine Mängel, keine Einsätze.
|
||||||
|
- Wer aus der Wehr austritt, verliert das Abonnement.
|
||||||
|
|
||||||
|
### Die vier Kanäle
|
||||||
|
|
||||||
|
| Kanal | Läuft über | Bekommt |
|
||||||
|
|---|---|---|
|
||||||
|
| **E-Mail** (SMTP) | den eigenen bzw. den Mailserver der Gemeinde | den ausführlichen Text |
|
||||||
|
| **ntfy** (selbst gehostet) | den eigenen Server, Profil `push` | den ausführlichen Text |
|
||||||
|
| **Telegram** | einen fremden Server | nur die Kurzfassung |
|
||||||
|
| **Web-Push** | den Push-Dienst des Browserherstellers | nur die Kurzfassung |
|
||||||
|
|
||||||
|
Die Spalte ganz rechts ist keine Bequemlichkeit, sondern die Regel: was das Netz
|
||||||
|
der Wehr verlässt, bekommt einen Hinweis ohne Namen, Gerätebezeichnungen und
|
||||||
|
Einsatzdetails — „3 Prüfungen fällig", nicht welche. In der Oberfläche steht das
|
||||||
|
neben jedem externen Kanal, und die Vorlagenmaske sagt es noch einmal, bevor
|
||||||
|
jemand einen Namen in eine Telegram-Vorlage tippt.
|
||||||
|
|
||||||
|
**ntfy** ist der interessanteste Weg für eine Wehr: ein Container, eine App im
|
||||||
|
Store, kein Konto, keine Telefonnummer, kein Telefonbuchabgleich. Man abonniert
|
||||||
|
ein Thema — einen frei gewählten Namen — und bekommt Meldungen darauf. Läuft
|
||||||
|
der Server im Gerätehaus, verlässt keine Nachricht das Haus. Der Preis: ein
|
||||||
|
Thema ist nur so geheim wie sein Name, deshalb schlägt FLORIAN Namen mit
|
||||||
|
Zufallsanteil vor.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile push up -d # ntfy startet auf Port 8086
|
||||||
|
npm run vapid # Schlüsselpaar für Web-Push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Warum kein WhatsApp
|
||||||
|
|
||||||
|
Es gibt keine `WHATSAPP_`-Variablen, und das ist eine Entscheidung, keine
|
||||||
|
Lücke. Drei Gründe, in dieser Reihenfolge:
|
||||||
|
|
||||||
|
**Datenschutz.** Der einzige zulässige Weg für eine Organisation ist die
|
||||||
|
WhatsApp Business Platform. Nachrichten laufen über Server von Meta, und die
|
||||||
|
Wehr müsste einen Vertrag zur Auftragsverarbeitung schließen — für eine
|
||||||
|
Freiwillige Feuerwehr, deren Daten ausdrücklich im Gerätehaus bleiben sollen,
|
||||||
|
ist das der falsche Weg. Die gewöhnliche App scheidet ohnehin aus: sie gleicht
|
||||||
|
das Telefonbuch ab und gibt damit die Kontakte aller Mitglieder weiter.
|
||||||
|
|
||||||
|
**Kosten.** Abgerechnet wird je Konversation. Eine Wehr mit fünfzig Mitgliedern
|
||||||
|
und zwei Terminerinnerungen im Monat zahlt dauerhaft für etwas, das über den
|
||||||
|
eigenen Mailserver nichts kostet.
|
||||||
|
|
||||||
|
**Aufwand.** Business-Konto, verifizierte Telefonnummer, genehmigte
|
||||||
|
Nachrichtenvorlagen, ein Meta-Entwicklerkonto. Das ist mehr Verwaltung als der
|
||||||
|
gesamte Rest dieser Phase.
|
||||||
|
|
||||||
|
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
|
||||||
|
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts
|
||||||
|
und verrät niemandem etwas.
|
||||||
|
|
||||||
|
### Der digitale Dienstausweis
|
||||||
|
|
||||||
|
Ein QR-Code auf dem Handy, den man an der Absperrung, beim Lehrgang oder bei
|
||||||
|
einer Kontrolle vorzeigt. Wer ihn scannt, landet auf einer Prüfseite und sieht
|
||||||
|
Name, Wehr, Funktion, Foto und die gültigen Qualifikationen — **ohne sich
|
||||||
|
anzumelden**, denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||||||
|
|
||||||
|
- **Signiert, nicht gespeichert.** HMAC über Person, Wehr und Zeitstempel.
|
||||||
|
Keine Tabelle, kein Aufräumjob.
|
||||||
|
- **Fünf Minuten gültig.** Er wird vorgezeigt, abgelesen und weggesteckt; ein
|
||||||
|
abfotografierter Code ist danach wertlos. Die Prüfseite nennt deshalb den
|
||||||
|
Zeitpunkt der Ausstellung — ein Ausweis, der „gültig" sagt, ohne zu sagen
|
||||||
|
seit wann, ist wertlos.
|
||||||
|
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein. Wer den
|
||||||
|
Code hat, bekommt keinen Zugang zu einem einzigen Datensatz.
|
||||||
|
- Was **nicht** darauf steht: Anschrift, Geburtsdatum, Telefonnummer,
|
||||||
|
Mitgliedsnummer. Ein Ausweis weist aus; er ist keine Personalakte.
|
||||||
|
- Ein gültig signierter Code für jemanden, der ausgetreten ist, wird abgewiesen.
|
||||||
|
|
||||||
|
### Was von selbst benachrichtigt
|
||||||
|
|
||||||
|
Sechs Anlässe, an die vorhandenen Abläufe angehängt:
|
||||||
|
|
||||||
|
| Anlass | Wann | An wen |
|
||||||
|
|---|---|---|
|
||||||
|
| Prüfung fällig | im Erinnerungslauf | wer es abonniert hat |
|
||||||
|
| Mangel gemeldet | sofort | wer es abonniert hat |
|
||||||
|
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher | die Person selbst |
|
||||||
|
| Neuer Termin | beim Anlegen | wer es abonniert hat |
|
||||||
|
| Terminerinnerung | 7 Tage und 1 Tag vorher | Angemeldete, sonst Abonnenten |
|
||||||
|
| Einsatzbericht freigegeben | bei der Freigabe | wer es abonniert hat |
|
||||||
|
|
||||||
|
Die Sammelmail an den zuständigen Gerätewart bleibt, wie sie war — sie geht an
|
||||||
|
den, der zuständig ist, unabhängig davon, ob er irgendwo ein Kreuzchen gesetzt
|
||||||
|
hat. Ein Prüftermin ist eine Pflicht, kein Abo. Wer sie ohnehin bekommt, wird
|
||||||
|
von der Benachrichtigung ausgenommen und hat sie damit nicht doppelt.
|
||||||
|
|
||||||
|
Erinnert wird um sieben Uhr morgens, nicht nachts: eine Push-Meldung um drei Uhr
|
||||||
|
weckt Leute, die am nächsten Tag arbeiten müssen. Zugestellt wird dagegen jede
|
||||||
|
Minute — eine Absage am Dienstagabend muss ankommen, solange sie noch etwas
|
||||||
|
nützt.
|
||||||
|
|
||||||
|
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird nur eine
|
||||||
|
Zeile geschrieben; der Worker schickt sie weg und vermerkt, wie es ausging.
|
||||||
|
Sonst hinge das Speichern eines Termins am Antwortverhalten von Telegram. Eine
|
||||||
|
fehlgeschlagene Zustellung bleibt sichtbar stehen und wird nicht nach Frist
|
||||||
|
abgeräumt — sie ist der Hinweis darauf, dass ein Kanal kaputt ist, und der
|
||||||
|
verliert seinen Wert nicht durch Zeitablauf.
|
||||||
|
|
||||||
|
Der Probeknopf steht direkt an jedem Zustellweg und läuft durch dieselbe
|
||||||
|
Maschinerie wie alles andere. Ohne ihn merkt jemand erst in drei Wochen, dass
|
||||||
|
sein ntfy-Thema einen Tippfehler hat — nämlich dann, wenn die Nachricht wichtig
|
||||||
|
gewesen wäre.
|
||||||
|
|
||||||
|
### Zusätzliche Umgebungsvariablen
|
||||||
|
|
||||||
|
| Variable | Vorgabe | Bedeutung |
|
||||||
|
|---|---|---|
|
||||||
|
| `NTFY_ENABLED` | `false` | Push über den eigenen ntfy |
|
||||||
|
| `NTFY_URL` | — | Adresse des eigenen ntfy, z. B. `http://ntfy:80` |
|
||||||
|
| `NTFY_TOKEN` | — | nur bei eingeschaltetem Zugriffsschutz |
|
||||||
|
| `NTFY_PORT` | `8086` | Port des ntfy-Containers (Profil `push`) |
|
||||||
|
| `WEBPUSH_ENABLED` | `false` | Push in den Browser |
|
||||||
|
| `WEBPUSH_PUBLIC_KEY` | — | aus `npm run vapid`; **ins Backup** |
|
||||||
|
| `WEBPUSH_PRIVATE_KEY` | — | dito |
|
||||||
|
| `WEBPUSH_KONTAKT` | `APP_URL` | Kontaktweg für den Push-Dienst |
|
||||||
|
| `BENACHRICHTIGUNG_LAUF` | `0 7 * * *` | wann erinnert wird |
|
||||||
|
| `TERMIN_ERINNERUNG_TAGE` | `7,1` | Vorlauf der Terminerinnerungen |
|
||||||
|
| `BENACHRICHTIGUNG_VERSUCHE` | `3` | Zustellversuche, bevor es stehen bleibt |
|
||||||
|
| `BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE` | `90` | Frist für **zugestellte** Nachrichten |
|
||||||
|
|
||||||
|
Ein neuer öffentlicher VAPID-Schlüssel macht alle bestehenden Push-Abos
|
||||||
|
wertlos — jeder müsste Push von Hand neu erlauben, und niemand merkt es, die
|
||||||
|
Meldungen bleiben einfach aus. Beide Schlüssel gehören deshalb ins Backup.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Befehle
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -798,6 +986,7 @@ npm run db:migrate # ausstehende Migrationen anwenden
|
|||||||
npm run db:seed # Beispielwehr einspielen
|
npm run db:seed # Beispielwehr einspielen
|
||||||
npm run db:studio # Prisma Studio
|
npm run db:studio # Prisma Studio
|
||||||
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
|
npm run screenshots # Bilder für docs/ANLEITUNG.md erzeugen
|
||||||
|
npm run vapid # VAPID-Schlüsselpaar für Web-Push erzeugen
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dienste und Ports (Entwicklung)
|
## Dienste und Ports (Entwicklung)
|
||||||
@@ -836,9 +1025,12 @@ tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
|||||||
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
Schriften verwendet, die auf dem Gerät vorhanden sind.
|
||||||
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
- Aufbewahrungsfristen konfigurierbar (`AUFBEWAHRUNG_JAHRE`,
|
||||||
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
|
`AUDIT_AUFBEWAHRUNG_TAGE`, `PERSON_ANONYMISIERUNG_TAGE`).
|
||||||
- Externe Dienste (Mail, Telegram, LLM) sitzen hinter Schnittstellen in
|
- Externe Dienste (Mail, Telegram, ntfy, Web-Push, LLM) sitzen hinter
|
||||||
`src/lib/integrations/` und sind einzeln abschaltbar. Ohne Freischaltung
|
Schnittstellen in `src/lib/integrations/` und sind einzeln abschaltbar. Ohne
|
||||||
wird nur ins Log geschrieben.
|
Freischaltung wird nur ins Log geschrieben.
|
||||||
|
- Über Kanäle, die das Netz der Wehr verlassen (Telegram, Web-Push), gehen nur
|
||||||
|
Kurzfassungen ohne Namen, Gerätebezeichnungen und Einsatzdetails.
|
||||||
|
- Kein WhatsApp — die Begründung steht oben unter „Warum kein WhatsApp".
|
||||||
|
|
||||||
## Sicherheitshinweis
|
## Sicherheitshinweis
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,18 @@ x-app-env: &app-env
|
|||||||
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
TELEGRAM_ENABLED: ${TELEGRAM_ENABLED:-false}
|
||||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
NTFY_ENABLED: ${NTFY_ENABLED:-false}
|
||||||
|
NTFY_URL: ${NTFY_URL:-}
|
||||||
|
NTFY_TOKEN: ${NTFY_TOKEN:-}
|
||||||
|
NTFY_TIMEOUT: ${NTFY_TIMEOUT:-10}
|
||||||
|
WEBPUSH_ENABLED: ${WEBPUSH_ENABLED:-false}
|
||||||
|
WEBPUSH_PUBLIC_KEY: ${WEBPUSH_PUBLIC_KEY:-}
|
||||||
|
WEBPUSH_PRIVATE_KEY: ${WEBPUSH_PRIVATE_KEY:-}
|
||||||
|
WEBPUSH_KONTAKT: ${WEBPUSH_KONTAKT:-}
|
||||||
|
BENACHRICHTIGUNG_LAUF: ${BENACHRICHTIGUNG_LAUF:-0 7 * * *}
|
||||||
|
TERMIN_ERINNERUNG_TAGE: ${TERMIN_ERINNERUNG_TAGE:-7,1}
|
||||||
|
BENACHRICHTIGUNG_VERSUCHE: ${BENACHRICHTIGUNG_VERSUCHE:-3}
|
||||||
|
BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE: ${BENACHRICHTIGUNG_AUFBEWAHRUNG_TAGE:-90}
|
||||||
LLM_ENABLED: ${LLM_ENABLED:-false}
|
LLM_ENABLED: ${LLM_ENABLED:-false}
|
||||||
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
LLM_BASE_URL: ${LLM_BASE_URL:-}
|
||||||
LLM_API_KEY: ${LLM_API_KEY:-}
|
LLM_API_KEY: ${LLM_API_KEY:-}
|
||||||
@@ -306,6 +318,46 @@ services:
|
|||||||
- intern
|
- intern
|
||||||
logging: *logging
|
logging: *logging
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ntfy — Push aufs Handy, ohne dass etwas das Haus verlässt (Phase 7).
|
||||||
|
#
|
||||||
|
# Nur mit Profil: docker compose --profile push up -d
|
||||||
|
#
|
||||||
|
# Warum das der interessanteste Push-Weg für eine Wehr ist: kein Konto, keine
|
||||||
|
# Telefonnummer, kein Telefonbuchabgleich. Man abonniert in der App ein
|
||||||
|
# Thema — einen frei gewählten Namen — und bekommt Meldungen darauf.
|
||||||
|
#
|
||||||
|
# Der Container ist winzig und speichert standardmäßig nichts dauerhaft:
|
||||||
|
# eine zugestellte Meldung ist erledigt. Wer sie nachlesen können will,
|
||||||
|
# schaltet den Cache ein (siehe ntfy-Dokumentation) — dann liegen die Texte
|
||||||
|
# allerdings auch dort und nicht nur in FLORIAN.
|
||||||
|
#
|
||||||
|
# ACHTUNG: Ein Thema ist nur so geheim wie sein Name. Wer ihn kennt, liest
|
||||||
|
# mit. Deshalb schlägt FLORIAN Namen mit Zufallsanteil vor, und deshalb
|
||||||
|
# steht NTFY_TOKEN bereit, falls der eigene ntfy Zugriffsschutz bekommt.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
ntfy:
|
||||||
|
image: binwiederhier/ntfy:v2.11.0
|
||||||
|
container_name: florian-ntfy
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: [push]
|
||||||
|
command: ["serve"]
|
||||||
|
environment:
|
||||||
|
NTFY_BASE_URL: ${NTFY_OEFFENTLICH:-http://localhost:8086}
|
||||||
|
NTFY_LISTEN_HTTP: ":80"
|
||||||
|
# Keine Anmeldung nötig, um zu lesen und zu schreiben — geschützt ist
|
||||||
|
# das Ding durch die Unerratbarkeit der Themennamen und dadurch, dass es
|
||||||
|
# im eigenen Netz steht. Wer es ins Internet stellt, setzt
|
||||||
|
# NTFY_AUTH_DEFAULT_ACCESS auf "deny-all" und vergibt Token.
|
||||||
|
NTFY_AUTH_DEFAULT_ACCESS: ${NTFY_ZUGRIFF:-read-write}
|
||||||
|
NTFY_BEHIND_PROXY: "true"
|
||||||
|
ports:
|
||||||
|
- "${NTFY_PORT:-8086}:80"
|
||||||
|
networks:
|
||||||
|
- edge
|
||||||
|
- intern
|
||||||
|
logging: *logging
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Die Anwendung selbst (Next.js, Standalone-Build).
|
# Die Anwendung selbst (Next.js, Standalone-Build).
|
||||||
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
# Führt vor dem Start die ausstehenden Prisma-Migrationen aus.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Anleitung
|
# Anleitung
|
||||||
|
|
||||||
Bedienung von FLORIAN, Stand Phase 6 (Version 0.7.0). Die Bilder zeigen die
|
Bedienung von FLORIAN, Stand Phase 7 (Version 0.8.0). Die Bilder zeigen die
|
||||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
||||||
|
|
||||||
> Benachrichtigungen, lokale KI und die Kassenverwaltung kommen in den
|
> Die lokale KI und die Kassenverwaltung kommen in den folgenden Phasen dazu.
|
||||||
> folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
|
> Siehe [ROADMAP.md](ROADMAP.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1279,7 +1279,230 @@ GEOCODING_LAENDER=de,at,ch
|
|||||||
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
Ohne Konfiguration bleibt die Suche leer. Koordinaten lassen sich dann von Hand
|
||||||
eintragen oder weglassen.
|
eintragen oder weglassen.
|
||||||
|
|
||||||
## 39. Rollen und was sie dürfen
|
## 39. Der Terminkalender
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Termine** steht, was ansteht: Dienste, Übungen, Lehrgänge,
|
||||||
|
Arbeitsdienste, Veranstaltungen, Sitzungen. Die Liste blickt nach vorn — das
|
||||||
|
ist, was fast immer gemeint ist.
|
||||||
|
|
||||||
|
An jeder Zeile stehen zwei Knöpfe: **Zusagen** und **Absagen**. Mehr braucht
|
||||||
|
das normale Mitglied nicht.
|
||||||
|
|
||||||
|
### Rückmeldung ist die Ausnahme
|
||||||
|
|
||||||
|
Beim Übungsabend meldet sich niemand an, man kommt oder man kommt nicht. Erst
|
||||||
|
beim Lehrgang mit zwölf Plätzen und beim Ausflug mit Busbestellung wird beim
|
||||||
|
Anlegen **Rückmeldung verlangen** angekreuzt.
|
||||||
|
|
||||||
|
Wer dann Plätze begrenzt, bekommt automatisch eine **Warteliste**: Zusagen über
|
||||||
|
die Platzzahl hinaus landen dort, nummeriert in der Reihenfolge der Anmeldung.
|
||||||
|
Sagt jemand ab, rückt der Erste von selbst nach und erfährt es. Niemand muss
|
||||||
|
eine Liste führen.
|
||||||
|
|
||||||
|
### Serientermine
|
||||||
|
|
||||||
|
Beim Anlegen unter **Wiederholung** wählen: wöchentlich, alle zwei Wochen,
|
||||||
|
monatlich, jährlich. Dazu ein Datum, bis zu dem die Serie läuft.
|
||||||
|
|
||||||
|
Darunter steht dann in einem Kasten, **wie viele Termine daraus werden** —
|
||||||
|
lesen Sie das, bevor Sie auf „Anlegen" drücken. Wöchentlich über ein Jahr sind
|
||||||
|
52 Einträge.
|
||||||
|
|
||||||
|
FLORIAN legt jeden Abend als eigenen Termin an, nicht als Regel. Das ist
|
||||||
|
Absicht: so lässt sich der Abend im August absagen, der im Dezember eine Woche
|
||||||
|
vorziehen, und für jeden gibt es eine eigene Teilnehmer- und Anwesenheitsliste.
|
||||||
|
Bei „monatlich" bleibt der Tag im Monat gleich — der 31. rutscht im Februar auf
|
||||||
|
den 28., nicht in den März.
|
||||||
|
|
||||||
|
### Absagen statt löschen
|
||||||
|
|
||||||
|
Ein Termin, zu dem schon jemand zugesagt hat, lässt sich nicht löschen. Er wird
|
||||||
|
**abgesagt**, mit Grund. Er bleibt dann durchgestrichen im Kalender stehen, der
|
||||||
|
Grund steht daneben, und wer zugesagt hatte, bekommt eine Nachricht — nicht die
|
||||||
|
ganze Wehr.
|
||||||
|
|
||||||
|
Gehört der Termin zu einer Serie, können Sie im selben Schritt **alle folgenden
|
||||||
|
Termine** absagen. Vergangene bleiben unberührt; man sagt nicht rückwirkend ab,
|
||||||
|
was schon stattgefunden hat.
|
||||||
|
|
||||||
|
### Anwesenheitsliste
|
||||||
|
|
||||||
|
Auf jedem Termin oben rechts. Sie zeigt **alle aktiven Mitglieder**, nicht nur
|
||||||
|
die Angemeldeten — auf einem Blatt zum Abhaken muss jeder stehen. Daneben, was
|
||||||
|
jemand gemeldet hatte.
|
||||||
|
|
||||||
|
Wer das Recht dazu hat, hakt gleich hier ab; sonst führt **Drucken** zu einem
|
||||||
|
Blatt mit leeren Kästchen.
|
||||||
|
|
||||||
|
> Zusage und Anwesenheit sind zwei verschiedene Dinge. Wer zugesagt hat und
|
||||||
|
> nicht kam, steht genau so in der Liste — das ist der Zweck der Trennung.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 40. Benachrichtigungen einrichten
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> **Niemand wird ungefragt benachrichtigt.** Wer hier nichts einträgt, bekommt
|
||||||
|
> nichts. Es gibt keine Voreinstellung, die alle anmeldet.
|
||||||
|
|
||||||
|
Unter **Benachrichtigungen** richten Sie Ihre eigenen Wege ein — Ihre, nicht
|
||||||
|
die von jemand anderem. Auch der Kommandant kann niemanden für Sie anmelden.
|
||||||
|
|
||||||
|
### Die vier Wege
|
||||||
|
|
||||||
|
| Weg | Was Sie brauchen | Bleibt im Haus? |
|
||||||
|
|---|---|---|
|
||||||
|
| **E-Mail** | nichts, es gilt Ihre hinterlegte Adresse | ja |
|
||||||
|
| **ntfy** | die ntfy-App und ein Thema | ja, wenn der Server der Wehr gehört |
|
||||||
|
| **Telegram** | Ihre Chat-Kennung vom Bot | **nein** |
|
||||||
|
| **Browser-Push** | nur die Erlaubnis des Browsers | **nein** |
|
||||||
|
|
||||||
|
Neben den Wegen, die das Netz der Wehr verlassen, steht ein Warnhinweis. Dorthin
|
||||||
|
gehen nur **Kurzfassungen**: „3 Prüfungen fällig", nicht welche. Wer es genau
|
||||||
|
wissen will, bekommt es per E-Mail oder schaut in FLORIAN nach.
|
||||||
|
|
||||||
|
### ntfy einrichten
|
||||||
|
|
||||||
|
Der bequemste Weg, wenn die Wehr einen eigenen ntfy betreibt:
|
||||||
|
|
||||||
|
1. Die App „ntfy" aus dem Store laden.
|
||||||
|
2. In der App den Server eintragen, der auf dieser Seite steht.
|
||||||
|
3. In FLORIAN **ntfy** wählen; das vorgeschlagene Thema übernehmen.
|
||||||
|
4. In der App dasselbe Thema abonnieren.
|
||||||
|
|
||||||
|
> **Das Thema ist Ihr Schlüssel.** Wer den Namen kennt, liest mit. Deshalb
|
||||||
|
> schlägt FLORIAN einen zufälligen vor — nehmen Sie nicht „florian-mueller".
|
||||||
|
|
||||||
|
### Browser-Push
|
||||||
|
|
||||||
|
**Einrichten** drücken; der Browser fragt nach Erlaubnis. Das Abo gilt für
|
||||||
|
**diesen einen Browser auf diesem einen Gerät** — auf dem Diensthandy und am
|
||||||
|
Rechner im Büro also zweimal einrichten.
|
||||||
|
|
||||||
|
Auf dem iPhone funktioniert es erst, wenn FLORIAN über „Zum Home-Bildschirm"
|
||||||
|
abgelegt wurde. Vorher bietet der Browser es gar nicht an.
|
||||||
|
|
||||||
|
### Wofür?
|
||||||
|
|
||||||
|
Unter jedem Weg stehen die sechs Anlässe zum Ankreuzen:
|
||||||
|
|
||||||
|
| Anlass | Wann er kommt |
|
||||||
|
|---|---|
|
||||||
|
| Prüfung fällig | im morgendlichen Lauf |
|
||||||
|
| Mangel gemeldet | sofort |
|
||||||
|
| Qualifikation läuft ab | 90, 30 und 7 Tage vorher |
|
||||||
|
| Neuer Termin | beim Anlegen |
|
||||||
|
| Erinnerung an einen Termin | 7 Tage und 1 Tag vorher |
|
||||||
|
| Einsatzbericht freigegeben | bei der Freigabe |
|
||||||
|
|
||||||
|
### Der Probeknopf
|
||||||
|
|
||||||
|
**Drücken Sie ihn.** Er steht direkt an jedem Weg und schickt eine
|
||||||
|
Probenachricht durch dieselbe Maschinerie wie alles andere. Ohne ihn merken Sie
|
||||||
|
einen Tippfehler im Thema erst in drei Wochen — dann, wenn die Nachricht
|
||||||
|
wichtig gewesen wäre.
|
||||||
|
|
||||||
|
Sie geht mit dem nächsten Lauf raus, üblicherweise binnen einer Minute.
|
||||||
|
|
||||||
|
### Was ankam
|
||||||
|
|
||||||
|
Ganz unten steht, was FLORIAN Ihnen zuletzt geschickt hat und ob es angekommen
|
||||||
|
ist. Steht dort mehrfach **Fehlgeschlagen**, ist der Weg kaputt — der Grund
|
||||||
|
steht daneben.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 41. Den Kalender aufs Handy holen
|
||||||
|
|
||||||
|
Auf derselben Seite unter **Kalender abonnieren**. Namen für das Gerät eingeben,
|
||||||
|
**Adresse erzeugen** — und die Adresse gleich übernehmen.
|
||||||
|
|
||||||
|
> **Sie wird genau einmal angezeigt.** FLORIAN speichert nur ihre Prüfsumme.
|
||||||
|
> Wer sie verliert, erzeugt eine neue und widerruft die alte.
|
||||||
|
|
||||||
|
Die Adresse dann eintragen:
|
||||||
|
|
||||||
|
| Gerät | Weg |
|
||||||
|
|---|---|
|
||||||
|
| **iPhone/iPad** | Einstellungen → Kalender → Accounts → Account hinzufügen → Andere → Kalenderabo hinzufügen |
|
||||||
|
| **Android** | in Google Kalender am Rechner: Andere Kalender → Per URL |
|
||||||
|
| **Thunderbird** | Neuer Kalender → Im Netzwerk → iCalendar (ICS) |
|
||||||
|
| **Outlook** | Kalender hinzufügen → Aus dem Internet abonnieren |
|
||||||
|
|
||||||
|
Danach stehen die Termine der Wehr neben allem anderen im Kalender. Neue
|
||||||
|
Termine und Absagen kommen von selbst nach; die meisten Programme fragen
|
||||||
|
stündlich nach.
|
||||||
|
|
||||||
|
**Nur eigene Termine** angekreuzt heißt: nur die, zu denen Sie sich
|
||||||
|
zurückgemeldet haben.
|
||||||
|
|
||||||
|
> **Die Adresse ist ein Schlüssel.** Wer sie hat, sieht die Termine der Wehr —
|
||||||
|
> ohne Anmeldung. Geben Sie sie nicht weiter; bei einem verlorenen Handy
|
||||||
|
> genügt ein Klick auf **Widerrufen**.
|
||||||
|
|
||||||
|
Ausgeliefert werden ausschließlich Termine — keine Teilnehmerlisten, keine
|
||||||
|
Mängel, keine Einsätze. Wer aus der Wehr austritt, verliert das Abo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 42. Textvorlagen und der Dienstausweis
|
||||||
|
|
||||||
|
### Textvorlagen
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Für Kommandant, Schriftführer und Administrator: unter **Benachrichtigungen →
|
||||||
|
Vorlagen bearbeiten** steht, was in den Nachrichten steht. Was hier geändert
|
||||||
|
wird, gilt für **alle**.
|
||||||
|
|
||||||
|
Links der Text, rechts die Vorschau mit erfundenen Beispielwerten. Die
|
||||||
|
**Platzhalter** darunter sind anklickbar und werden beim Versand ersetzt:
|
||||||
|
`{titel}` wird zu „Übungsabend", `{datum}` zum Datum.
|
||||||
|
|
||||||
|
Ein Platzhalter, den es für diesen Anlass nicht gibt, wird beim Speichern
|
||||||
|
abgewiesen — mit der Liste der erlaubten daneben. Und einer, der stehen bleibt,
|
||||||
|
bleibt auch in der fertigen Nachricht sichtbar: eine Lücke, die niemand
|
||||||
|
bemerkt, wäre schlimmer.
|
||||||
|
|
||||||
|
Ohne eigene Vorlage greift der eingebaute Text. **Auf Standard zurücksetzen**
|
||||||
|
stellt ihn wieder her.
|
||||||
|
|
||||||
|
> Bei Telegram und Browser-Push steht ein Warnhinweis über dem Feld. Was Sie
|
||||||
|
> dort hineinschreiben, läuft über einen fremden Server — also keine Namen,
|
||||||
|
> keine Adressen, keine Einsatzdetails.
|
||||||
|
|
||||||
|
### Der digitale Dienstausweis
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Unter **Dienstausweis**: ein QR-Code mit Ihrem Namen, Ihrer Wehr, Ihrer
|
||||||
|
Funktion und Ihren gültigen Qualifikationen. Zum Vorzeigen an der Absperrung,
|
||||||
|
beim Lehrgang oder bei einer Kontrolle.
|
||||||
|
|
||||||
|
Wer ihn scannt, landet auf einer Prüfseite und sieht genau das — **ohne sich
|
||||||
|
anzumelden**. Denn wer prüft, hat keinen Zugang zu FLORIAN.
|
||||||
|
|
||||||
|
Drei Dinge sollten Sie darüber wissen:
|
||||||
|
|
||||||
|
- **Der Code gilt fünf Minuten.** Danach zeigt die Seite ihn selbst neu an. Ein
|
||||||
|
abfotografierter Code nützt nichts.
|
||||||
|
- **Er öffnet nichts.** Die Prüfseite zeigt an, sie lässt nicht hinein.
|
||||||
|
- **Es steht nicht mehr darauf als auf einem Ausweis aus Papier.** Keine
|
||||||
|
Anschrift, kein Geburtsdatum, keine Telefonnummer.
|
||||||
|
|
||||||
|
Die Prüfseite nennt außerdem, wann der Code erzeugt wurde. Das ist der Punkt,
|
||||||
|
auf den ein Prüfender schauen sollte: „vor zwei Minuten" ist ein Ausweis,
|
||||||
|
„gültig" allein wäre wertlos.
|
||||||
|
|
||||||
|
Wer aus der Wehr ausgetreten ist, bekommt dort **Kein aktives Mitglied** — auch
|
||||||
|
wenn sein Code technisch noch stimmt.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 43. Rollen und was sie dürfen
|
||||||
|
|
||||||
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||||
@@ -1292,9 +1515,9 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
|
|||||||
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
|
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
|
||||||
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
|
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
|
||||||
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
|
| **Kassier** | Buchungen und Kassenabschluss (Modul noch nicht geplant, siehe Roadmap) |
|
||||||
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen |
|
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||||
| **Ausbilder** | Übungen planen, Teilnahme und Qualifikationen erfassen |
|
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
||||||
| **Mitglied** | eigene Daten, Listen lesen |
|
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten |
|
||||||
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
||||||
|
|
||||||
Zwei Dinge sind bewusst so gebaut:
|
Zwei Dinge sind bewusst so gebaut:
|
||||||
@@ -1306,9 +1529,14 @@ Trennung nach Verantwortung, nicht nach Bequemlichkeit.
|
|||||||
zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
|
zusätzlich mit PIN oder Karte an und handelt dann unter seiner eigenen Person.
|
||||||
Das Terminal selbst ist nur die Tür.
|
Das Terminal selbst ist nur die Tür.
|
||||||
|
|
||||||
|
**Seine Benachrichtigungswege richtet jeder selbst ein.** Das ist ein
|
||||||
|
Grundrecht jedes Mitglieds und keine Sache des Kommandanten — es gibt bewusst
|
||||||
|
keinen Weg, jemand anderem einen Zustellweg einzutragen. Was für alle gilt,
|
||||||
|
sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 40. Anmeldung über authentik einrichten
|
## 44. Anmeldung über authentik einrichten
|
||||||
|
|
||||||
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
|
||||||
|
|
||||||
@@ -1361,7 +1589,7 @@ ganze Wehr aus.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 41. PIN und Ausweiskarte verwalten
|
## 45. PIN und Ausweiskarte verwalten
|
||||||
|
|
||||||
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
was dahinter liegt.
|
||||||
@@ -1384,7 +1612,7 @@ Huber), PIN `471203`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 42. Was protokolliert wird
|
## 46. Was protokolliert wird
|
||||||
|
|
||||||
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||||
@@ -1409,7 +1637,7 @@ jeweiligen Detailseite sichtbar.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 43. Wenn etwas nicht geht
|
## 47. Wenn etwas nicht geht
|
||||||
|
|
||||||
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -1438,6 +1666,17 @@ jeweiligen Detailseite sichtbar.
|
|||||||
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
|
| Nach dem Import steht überall „ungeprüft" | Die Quelle hat keine Statusspalte | Absicht: ohne Angabe wird nicht „einsatzbereit" behauptet. Zustand nachpflegen |
|
||||||
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
| Die Lagekarte gleicht nicht ab | Der Ereignisstrom ist unterbrochen | Steht oben in der Lagekarte. Bei einem Reverse Proxy `X-Accel-Buffering: no` durchreichen |
|
||||||
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
|
| Die Förderstrecke braucht unerwartet viele Pumpen | Fördermenge zu hoch für den Durchmesser | Der Verlust steigt im Quadrat: 800 statt 400 l/min kostet die vierfache Reibung. Größeren Schlauch wählen oder weniger fördern |
|
||||||
|
| Es kommt keine Benachrichtigung an | Kein Zustellweg eingerichtet, oder kein Anlass angekreuzt | Unter **Benachrichtigungen** einen Weg einrichten und ankreuzen, wofür. Ohne beides kommt absichtlich nichts |
|
||||||
|
| Die Probenachricht bleibt aus | Der Kanal ist auf dem Server nicht eingerichtet | In der Auswahl steht „(nicht eingerichtet)". `MAIL_ENABLED`, `NTFY_ENABLED`, `TELEGRAM_ENABLED` oder `WEBPUSH_ENABLED` setzen |
|
||||||
|
| Im Verlauf steht mehrfach „Fehlgeschlagen" | Der Weg ist kaputt — der Grund steht daneben | Bei ntfy meist ein Tippfehler im Thema, bei Telegram eine falsche Chat-Kennung |
|
||||||
|
| Browser-Push lässt sich nicht einrichten | Keine VAPID-Schlüssel auf dem Server | `npm run vapid` und die Ausgabe in die `.env` übernehmen |
|
||||||
|
| Push kam bisher an, jetzt nicht mehr | Der öffentliche VAPID-Schlüssel wurde gewechselt | Alle Abos sind damit wertlos. Jeder muss Push neu erlauben — deshalb gehören die Schlüssel ins Backup |
|
||||||
|
| Auf dem iPhone gibt es keinen Push-Knopf | Web-Push geht dort nur als abgelegte App | Über „Teilen → Zum Home-Bildschirm" ablegen, dann erneut versuchen |
|
||||||
|
| Der Kalender im Handy aktualisiert sich nicht | Das Programm fragt nur stündlich nach | Abwarten oder im Kalenderprogramm „Aktualisieren" auslösen |
|
||||||
|
| Der Kalender ist plötzlich leer | Der Zugang wurde widerrufen, oder die Person ist ausgetreten | Unter **Benachrichtigungen** eine neue Adresse erzeugen |
|
||||||
|
| Eine Vorlage lässt sich nicht speichern | Ein Platzhalter gehört nicht zu diesem Anlass | Die Meldung nennt ihn und listet die erlaubten auf |
|
||||||
|
| Ein Termin lässt sich nicht löschen | Es liegen schon Rückmeldungen vor | Absagen statt löschen — dann sehen die Angemeldeten den Grund |
|
||||||
|
| Die Zusage wurde zur Warteliste | Die Plätze sind belegt | Sagt jemand ab, rücken Sie von selbst nach und werden benachrichtigt |
|
||||||
|
|
||||||
Zustand aller Randsysteme auf einen Blick:
|
Zustand aller Randsysteme auf einen Blick:
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Aufwandsschätzung
|
# Aufwandsschätzung
|
||||||
|
|
||||||
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
|
Schätzung je Phase, daneben der tatsächliche Aufwand aus dem
|
||||||
[Devlog](DEVLOG.md). Stand: 3. September 2026.
|
[Devlog](DEVLOG.md). Stand: 4. September 2026.
|
||||||
|
|
||||||
## Wie hier gerechnet wird
|
## Wie hier gerechnet wird
|
||||||
|
|
||||||
@@ -34,10 +34,10 @@ Faktoren, die in die Schätzung eingehen:
|
|||||||
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **2:05 h** |
|
||||||
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | **2:25 h** |
|
||||||
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | **2:10 h** |
|
||||||
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | offen |
|
| 4 | Kiosk-PWA mit Offline | 16–22 PT | 4–5,5 h | **3:00 h** |
|
||||||
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | offen |
|
| 5 | Atemschutz (FwDV 7) | 14–18 PT | 3,5–4,5 h | **2:40 h** |
|
||||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
|
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
|
||||||
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | offen |
|
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
|
||||||
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
|
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
|
||||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
||||||
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
| | **Summe** | **123–164 PT** | **28–37 h** | |
|
||||||
@@ -339,7 +339,7 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
|||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine
|
## Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
**Vergleichsaufwand: 10–13 PT**
|
**Vergleichsaufwand: 10–13 PT** · **Tatsächlich: 3:10 h Sitzungszeit**
|
||||||
|
|
||||||
| Baustein | PT |
|
| Baustein | PT |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -347,11 +347,24 @@ das ehrlich angezeigt wird. In der Aufstellung steht davon keine Zeile.
|
|||||||
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
| Vorlagen mit Platzhaltern, je Ereignis und Rolle | 2,0 |
|
||||||
| Abonnements je Person | 1,0 |
|
| Abonnements je Person | 1,0 |
|
||||||
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
|
| Kalender mit Serienterminen und Selbstanmeldung | 3,0 |
|
||||||
| CalDAV-/ICS-Feed je Person mit Token | 1,5 |
|
| ICS-Feed je Person mit Token | 1,5 |
|
||||||
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
|
| Digitaler Dienstausweis mit signiertem QR und Prüfseite | 2,0 |
|
||||||
|
|
||||||
Das Grundgerüst der Kanalabstraktion steht bereits aus Phase 0
|
Das Grundgerüst der Kanalabstraktion stand bereits aus Phase 0
|
||||||
(`lib/integrations/`), das spart hier etwa einen Personentag.
|
(`lib/integrations/`), das hat etwa einen Personentag gespart.
|
||||||
|
|
||||||
|
Die einzige Phase bisher, die über der geschätzten Sitzungszeit liegt — und
|
||||||
|
zwar an einer Stelle, die in der Aufstellung nicht vorkommt. „Kalender mit
|
||||||
|
Serienterminen" klingt nach einer Datumsrechnung; teuer war die Entscheidung
|
||||||
|
**gegen** eine Regel und **für** ausgeschriebene Termine, und dann die
|
||||||
|
Kalenderarithmetik dahinter: `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||||
|
3. März, der 29. Februar existiert in vier von fünf Jahren nicht, und ein
|
||||||
|
Übungsabend muss über die ganze Serie auf demselben Wochentag bleiben. Das sind
|
||||||
|
drei Tests, die man erst schreibt, wenn man den Fall kennt.
|
||||||
|
|
||||||
|
Die 1,5 PT für den Feed waren dagegen zu hoch angesetzt, weil sie CalDAV
|
||||||
|
unterstellen. ICS ist ein Textformat; der ganze Erzeuger sind hundertsechzig
|
||||||
|
Zeilen samt Maskierung und Zeilenumbruch nach 75 Oktetten.
|
||||||
|
|
||||||
## Phase 8 — Lokale KI
|
## Phase 8 — Lokale KI
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,126 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
|
|||||||
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
| Phase 4 — Kiosk-PWA im Gerätehaus | 3:00 h | 1 | 03.09.2026 |
|
||||||
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
| Phase 5 — Atemschutz nach FwDV 7 | 2:40 h | 1 | 03.09.2026 |
|
||||||
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
| Phase 6 — Kartenmodule | 2:50 h | 1 | 04.09.2026 |
|
||||||
| **Summe** | **16:10 h** | **8** | |
|
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||||||
|
| **Summe** | **19:20 h** | **9** | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-04 · Phase 7 — Benachrichtigungen und Termine
|
||||||
|
|
||||||
|
**04:10 – 07:20 · 3:10 h · Version 0.8.0**
|
||||||
|
|
||||||
|
### Was entstanden ist
|
||||||
|
|
||||||
|
Der Jahresplan der Wehr und die Wege, auf denen die Mannschaft davon erfährt.
|
||||||
|
|
||||||
|
| Bereich | Dateien |
|
||||||
|
|---|---|
|
||||||
|
| Rechnen | `lib/termin/serie.ts`, `ics.ts`, `lib/benachrichtigung/vorlagen.ts`, `versand.ts`, `lib/dienstausweis.ts` |
|
||||||
|
| Kanäle | `lib/integrations/mail.ts` (echtes SMTP), `ntfy.ts`, `webpush.ts` |
|
||||||
|
| Server | `server/routers/termin/termin.ts`, `benachrichtigung.ts`, `stammdaten/dienstausweis.ts` |
|
||||||
|
| Öffentlich | `api/kalender/[token]`, `ausweis/[code]`, `api/ausweis/[code]/foto` |
|
||||||
|
| Oberfläche | `app/(app)/termine/`, `benachrichtigungen/`, `ausweis/` |
|
||||||
|
| Hintergrund | `worker/benachrichtigung.ts`, zwei neue Zeitpläne |
|
||||||
|
| Tests | 56 Unit, 17 E2E je Browser |
|
||||||
|
|
||||||
|
### Entscheidungen
|
||||||
|
|
||||||
|
**Niemand wird ungefragt benachrichtigt.** Ein neu eingerichteter Zustellweg
|
||||||
|
hat eine leere Ereignisliste. Es gibt keinen Weg, jemand anderem einen Kanal
|
||||||
|
einzutragen — auch nicht für den Kommandanten. Der Seed legt bewusst *keine*
|
||||||
|
Zustellkanäle an, obwohl das die Beispieldaten hübscher machen würde. Wer eine
|
||||||
|
Wehr ungefragt anmeldet, hat binnen einer Woche eine Wehr, die die
|
||||||
|
Benachrichtigungen abschaltet, und danach kommen auch die wichtigen nicht mehr
|
||||||
|
an.
|
||||||
|
|
||||||
|
**Serientermine werden ausgeschrieben, nicht gerechnet.** Eine Regel wäre
|
||||||
|
weniger Zeilen in der Datenbank und mehr Sonderfälle im Code: der Übungsabend
|
||||||
|
fällt im August aus, im Dezember ist er eine Woche früher, und für jeden
|
||||||
|
einzelnen gibt es Zusagen und eine Anwesenheitsliste. Ausgeschrieben ist jede
|
||||||
|
dieser Ausnahmen einfach ein Datensatz, den man ändert. Der Preis sind hundert
|
||||||
|
Zeilen für zwei Jahre — Kleingeld, und die Zahl steht vor dem Anlegen in der
|
||||||
|
Maske.
|
||||||
|
|
||||||
|
**Gerechnet wird auf dem Kalender, nicht in Millisekunden.** „Monatlich" heißt
|
||||||
|
derselbe Tag im nächsten Monat. `setMonth(+1)` auf dem 31. Januar ergibt den
|
||||||
|
3. März, weil der Februar keinen 31. hat — ein Fehler, der in einer
|
||||||
|
Übungsserie erst im Frühjahr auffällt. Fällt der Tag im Zielmonat aus, rutscht
|
||||||
|
der Termin auf den letzten des Monats.
|
||||||
|
|
||||||
|
**Anlegen und Zustellen sind getrennt.** Wo ein Ereignis entsteht, wird eine
|
||||||
|
Zeile geschrieben, sonst nichts. Der Worker nimmt sich, was auf WARTET steht.
|
||||||
|
Wären es eine Sache, hinge das Speichern eines Termins am Antwortverhalten von
|
||||||
|
Telegram. So ist die schlimmste Folge eines toten Mailservers eine
|
||||||
|
Benachrichtigung, die auf FEHLGESCHLAGEN steht und sichtbar ist.
|
||||||
|
|
||||||
|
**Intern bekommt den langen Text, extern die Kurzfassung.** Der Mailserver
|
||||||
|
einer Wehr ist ihr eigener oder der der Gemeinde, und ein selbst betriebener
|
||||||
|
ntfy steht im Gerätehaus — dorthin dürfen Namen, Gerätebezeichnungen und
|
||||||
|
Einsatznummern. Telegram und Web-Push laufen über fremde Server und bekommen
|
||||||
|
„3 Prüfungen fällig", nicht welche. Das steht in `KANAL_EXTERN` an einer
|
||||||
|
Stelle, wird im Test für *jeden* externen Kanal geprüft und in der Oberfläche
|
||||||
|
zweimal gesagt: neben dem Kanal und über dem Vorlagenfeld.
|
||||||
|
|
||||||
|
**Kein CalDAV, sondern ICS.** CalDAV ist ein Schreib-Lese-Protokoll mit
|
||||||
|
Rechteverwaltung und Konfliktauflösung. Gebraucht wird Lesen. Eine Adresse, die
|
||||||
|
eine ICS-Datei ausliefert, kann jeder Kalender — das Format ist eine Liste von
|
||||||
|
Zeilen, und die interessanten Teile (Zeitzone, Umbruch nach 75 Oktetten) stehen
|
||||||
|
in zwanzig Zeilen. Eine Abhängigkeit dafür wäre in einer Anwendung ohne
|
||||||
|
Netzzugang der schlechtere Tausch.
|
||||||
|
|
||||||
|
**Der Ausweis gilt fünf Minuten, die Anmeldemarke eine.** Beide sind mit
|
||||||
|
AUTH_SECRET signiert; was sie trennt, ist der Zweck im signierten Teil. Ohne
|
||||||
|
den ginge das eine als das andere durch, und der Ausweis wäre plötzlich ein
|
||||||
|
Anmeldeweg. Die fünf Minuten sind der Unterschied zwischen „wird gescannt" und
|
||||||
|
„wird vorgezeigt, abgelesen und weggesteckt" — und die Prüfseite nennt den
|
||||||
|
Zeitpunkt der Ausstellung, weil ein Ausweis, der „gültig" sagt ohne zu sagen
|
||||||
|
seit wann, wertlos ist.
|
||||||
|
|
||||||
|
**Fehlgeschlagene Benachrichtigungen werden nicht abgeräumt.** Der
|
||||||
|
Aufbewahrungslauf nimmt sich nur die zugestellten. Eine fehlgeschlagene ist der
|
||||||
|
Hinweis darauf, dass ein Kanal kaputt ist, und der verliert seinen Wert nicht
|
||||||
|
durch Zeitablauf.
|
||||||
|
|
||||||
|
**Erinnert wird um sieben, zugestellt jede Minute.** Eine Push-Meldung um drei
|
||||||
|
Uhr weckt Leute, die am nächsten Tag arbeiten müssen. Eine Absage am
|
||||||
|
Dienstagabend muss dagegen ankommen, solange sie noch etwas nützt.
|
||||||
|
|
||||||
|
### Wo es gehakt hat
|
||||||
|
|
||||||
|
| Problem | Ursache | Lösung |
|
||||||
|
|---|---|---|
|
||||||
|
| Die Vorlagenmaske überschrieb den gerade getippten Text | Der Effekt, der den passenden Text lädt, hing an `vorlagen.data`. Wird die Liste im Hintergrund nachgeladen — nach dem Speichern, beim Zurückkommen ins Fenster —, war die Eingabe weg | Merker auf `ereignis:kanal`; geladen wird nur beim Wechsel der Auswahl. Gefunden hat es der E2E-Test, nicht das Ausprobieren |
|
||||||
|
| `personId` wurde zweimal gesetzt und überschrieb sich | Einschränkung und Ausschluss als zwei Objekt-Einträge im selben `where` — der zweite gewinnt still | Beide in eine Bedingung zusammengeführt |
|
||||||
|
| Der Aufräumlauf durfte nicht löschen | `REVOKE DELETE ON "benachrichtigung"` gilt der Anwendungsrolle, und `fuerOrganisation` benutzt genau die | Über `ohneMandantenfilter` mit ausdrücklichem Mandantenfilter in der Bedingung — begründet, wie es die Regel verlangt |
|
||||||
|
| Zwei E2E-Selektoren trafen zu viel | „Absagen" steckt auch in „Termin absagen"; `{titel}` steht im Textfeld *und* auf dem Platzhalterknopf | `exact: true` und Suche innerhalb des Dialogs |
|
||||||
|
| Der Warteliste-Test hing an der Reihenfolge im Seed | Ob der angemeldete Nutzer schon einen Platz hat, entscheidet die Mitgliedsnummer | Test prüft jetzt die Anzeige der Warteliste — die gilt für alle |
|
||||||
|
|
||||||
|
### Abweichung von der Vorgabe
|
||||||
|
|
||||||
|
**Kein CalDAV**, obwohl Phase-7.md „CalDAV-/ICS-Feed" nennt. Begründung oben;
|
||||||
|
sie steht auch im README und in der Roadmap.
|
||||||
|
|
||||||
|
**Termine einer Serie werden einzeln geändert, nicht „alle folgenden".** Der
|
||||||
|
Fall ist selten und mehrdeutig — meint „alle folgenden" auch die, die jemand
|
||||||
|
schon von Hand verschoben hat? Absagen geht dagegen für die ganze Serie, weil
|
||||||
|
das der Fall ist, der wirklich vorkommt (Sommerpause, Bauarbeiten am
|
||||||
|
Gerätehaus).
|
||||||
|
|
||||||
|
**Kein Push für iOS ohne Umweg.** Web-Push funktioniert auf dem iPhone erst,
|
||||||
|
wenn FLORIAN zum Startbildschirm hinzugefügt wurde. Das steht in der
|
||||||
|
Oberfläche; ein eigener Weg dafür wäre eine App im Store, und die gibt es
|
||||||
|
bewusst nicht.
|
||||||
|
|
||||||
|
### Nachweise
|
||||||
|
|
||||||
|
- `npx tsc --noEmit` — ohne Ausgabe
|
||||||
|
- `npm run test` — 422 Tests grün, davon 15 neue gegen eine echte Datenbank
|
||||||
|
- `npx playwright test tests/e2e/termine.spec.ts` — 17 grün
|
||||||
|
- `npm run build` — 50 Routen, darunter `/termine`, `/benachrichtigungen`,
|
||||||
|
`/ausweis` und die beiden öffentlichen Endpunkte
|
||||||
|
- `npm run db:seed` — 19 Termine, Warteliste und abgehakte Anwesenheit
|
||||||
|
- `docker compose config` — ntfy im Profil `push` prüft durch
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ Phase 3 ████████████████████ fertig
|
|||||||
Phase 4 ████████████████████ fertig 0.5.0
|
Phase 4 ████████████████████ fertig 0.5.0
|
||||||
Phase 5 ████████████████████ fertig 0.6.0
|
Phase 5 ████████████████████ fertig 0.6.0
|
||||||
Phase 6 ████████████████████ fertig 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
|
Phase 7 ████████████████████ fertig 0.8.0
|
||||||
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
|
Phase 8 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
|
||||||
```
|
```
|
||||||
@@ -148,25 +148,53 @@ analogen, bis die Wehr ausdrücklich anders entscheidet.
|
|||||||
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
**Danach kann die Wehr:** auf der Anfahrt sehen, wo der nächste Hydrant liegt
|
||||||
und was ihn hergibt.
|
und was ihn hergibt.
|
||||||
|
|
||||||
## Phase 7 — Benachrichtigungen und Termine · als Nächstes
|
## Phase 7 — Benachrichtigungen und Termine · fertig
|
||||||
|
|
||||||
- Kanäle: E-Mail, Telegram, ntfy, Web-Push. WhatsApp bewusst nicht.
|
Die Wehr weiß, wann der nächste Dienst ist — ohne dass jemand eine Rundmail
|
||||||
- Vorlagen mit Platzhaltern, je Ereignis und Rolle konfigurierbar
|
tippt.
|
||||||
- Kalender: Dienste, Übungen, Lehrgänge, Arbeitsdienste; Serientermine,
|
|
||||||
Selbstanmeldung mit Teilnehmerlimit, Anwesenheitsliste zum Ausdrucken
|
- Terminkalender mit Dienst, Übung, Lehrgang, Arbeitsdienst, Veranstaltung und
|
||||||
- CalDAV-/ICS-Feed je Person
|
Sitzung. Serientermine werden ausgeschrieben, nicht als Regel gespeichert.
|
||||||
- Digitaler Dienstausweis mit signiertem QR und Prüfseite
|
- Selbstanmeldung mit Platzbegrenzung und einer Warteliste, die von selbst
|
||||||
|
nachrückt. Absage mit Grund, wahlweise für alle folgenden Termine einer Serie.
|
||||||
|
- Anwesenheitsliste über alle aktiven Mitglieder — zum Abhaken am Tablet oder
|
||||||
|
zum Ausdrucken. Zusage und Anwesenheit sind getrennt.
|
||||||
|
- Vier Kanäle hinter Schnittstellen: E-Mail (echtes SMTP statt Platzhalter),
|
||||||
|
ntfy (selbst gehostet), Telegram, Web-Push. Was das Netz der Wehr verlässt,
|
||||||
|
bekommt nur die Kurzfassung.
|
||||||
|
- Vorlagen je Ereignis und Kanal mit Platzhaltern, Vorschau und eingebautem
|
||||||
|
Standardtext für alles.
|
||||||
|
- ICS-Abonnement je Person, Token nur als Hash gespeichert, einzeln widerrufbar.
|
||||||
|
- Digitaler Dienstausweis: signierter QR, fünf Minuten gültig, Prüfseite ohne
|
||||||
|
Anmeldung.
|
||||||
|
|
||||||
|
**Danach kann die Wehr:** ihren Jahresplan führen, Rückmeldungen einsammeln,
|
||||||
|
Anwesenheit nachweisen und jedes Mitglied auf dem Weg erreichen, den es selbst
|
||||||
|
gewählt hat.
|
||||||
|
|
||||||
|
### Kein CalDAV
|
||||||
|
|
||||||
|
CalDAV ist ein Schreib-Lese-Protokoll mit Rechteverwaltung und
|
||||||
|
Konfliktauflösung. Gebraucht wird hier nur Lesen — die Termine entstehen in
|
||||||
|
FLORIAN, nicht im Kalender des Anwenders. Dafür genügt eine Adresse, die eine
|
||||||
|
ICS-Datei ausliefert; jeder Kalender kann das. CalDAV wäre ein Vielfaches an
|
||||||
|
Aufwand für dieselbe Wirkung.
|
||||||
|
|
||||||
### Warum kein WhatsApp
|
### Warum kein WhatsApp
|
||||||
|
|
||||||
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
Die WhatsApp Business API kostet je Konversation und verlangt einen
|
||||||
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
zugelassenen Anbieter dazwischen — laufende Kosten für eine Wehr, die keinen
|
||||||
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nummern aller
|
Etat dafür hat. Der schwerere Grund ist der Datenschutz: die Nachrichten liefen
|
||||||
Mitglieder gingen an Meta, was sich mit einer Anwendung, die bewusst im
|
über Server von Meta, die Wehr müsste einen Vertrag zur Auftragsverarbeitung
|
||||||
Gerätehaus steht, nicht verträgt. Telegram, ntfy und Web-Push decken denselben
|
schließen, und die gewöhnliche App gleicht zusätzlich das Telefonbuch ab. Das
|
||||||
Bedarf ab, ntfy sogar vollständig selbst gehostet.
|
verträgt sich nicht mit einer Anwendung, die bewusst im Gerätehaus steht.
|
||||||
|
Telegram, ntfy und Web-Push decken denselben Bedarf ab, ntfy sogar vollständig
|
||||||
|
selbst gehostet.
|
||||||
|
|
||||||
## Phase 8 — Lokale KI
|
Wer seine Wehr trotzdem über WhatsApp erreichen will, hat den Weg schon: die
|
||||||
|
Gruppe, die es ohnehin gibt. Ein Link zum Termin hineinkopiert kostet nichts.
|
||||||
|
|
||||||
|
## Phase 8 — Lokale KI · als Nächstes
|
||||||
|
|
||||||
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
|
Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 212 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 262 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 267 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 174 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 415 KiB After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 424 KiB After Width: | Height: | Size: 438 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 808 KiB |
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 831 KiB |
|
Before Width: | Height: | Size: 140 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 324 KiB |
|
Before Width: | Height: | Size: 318 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 1012 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 214 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 830 KiB After Width: | Height: | Size: 888 KiB |
|
Before Width: | Height: | Size: 846 KiB After Width: | Height: | Size: 904 KiB |
|
Before Width: | Height: | Size: 271 KiB After Width: | Height: | Size: 288 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 243 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 266 KiB |
|
Before Width: | Height: | Size: 663 KiB After Width: | Height: | Size: 719 KiB |
|
Before Width: | Height: | Size: 670 KiB After Width: | Height: | Size: 724 KiB |
|
Before Width: | Height: | Size: 362 KiB After Width: | Height: | Size: 378 KiB |
|
Before Width: | Height: | Size: 115 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 245 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 250 KiB After Width: | Height: | Size: 264 KiB |
|
Before Width: | Height: | Size: 535 KiB After Width: | Height: | Size: 539 KiB |
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 541 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 244 KiB |
|
Before Width: | Height: | Size: 235 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 143 KiB After Width: | Height: | Size: 143 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 185 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 184 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 204 KiB After Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 261 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 255 KiB After Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 190 KiB After Width: | Height: | Size: 204 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 157 KiB |
|
Before Width: | Height: | Size: 227 KiB After Width: | Height: | Size: 243 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 907 KiB |
|
After Width: | Height: | Size: 368 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 199 KiB |
|
After Width: | Height: | Size: 201 KiB |
@@ -42,6 +42,7 @@
|
|||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"papaparse": "^5.7.0",
|
"papaparse": "^5.7.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -49,6 +50,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -57,10 +59,12 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^22.18.6",
|
"@types/node": "^22.18.6",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.25.10",
|
"esbuild": "^0.25.10",
|
||||||
@@ -4445,6 +4449,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/nodemailer": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/papaparse": {
|
"node_modules/@types/papaparse": {
|
||||||
"version": "5.5.2",
|
"version": "5.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.5.2.tgz",
|
||||||
@@ -4485,6 +4499,16 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-push": {
|
||||||
|
"version": "3.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||||
|
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.69.0",
|
"version": "8.69.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
|
||||||
@@ -5331,7 +5355,6 @@
|
|||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 14"
|
"node": ">= 14"
|
||||||
@@ -5663,6 +5686,18 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/asn1.js": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bn.js": "^4.0.0",
|
||||||
|
"inherits": "^2.0.1",
|
||||||
|
"minimalistic-assert": "^1.0.0",
|
||||||
|
"safer-buffer": "^2.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/assertion-error": {
|
"node_modules/assertion-error": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||||
@@ -5838,6 +5873,12 @@
|
|||||||
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bn.js": {
|
||||||
|
"version": "4.12.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||||
|
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "1.1.18",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
@@ -5934,6 +5975,12 @@
|
|||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-equal-constant-time": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/buffer-indexof-polyfill": {
|
"node_modules/buffer-indexof-polyfill": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz",
|
||||||
@@ -6831,6 +6878,15 @@
|
|||||||
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
"integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ecdsa-sig-formatter": {
|
||||||
|
"version": "1.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||||
|
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/effect": {
|
"node_modules/effect": {
|
||||||
"version": "3.21.0",
|
"version": "3.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz",
|
||||||
@@ -8286,6 +8342,15 @@
|
|||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http_ece": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-proxy-agent": {
|
"node_modules/http-proxy-agent": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
|
||||||
@@ -8304,7 +8369,6 @@
|
|||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"agent-base": "^7.1.2",
|
"agent-base": "^7.1.2",
|
||||||
@@ -9147,6 +9211,27 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"safe-buffer": "~5.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jwa": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"buffer-equal-constant-time": "^1.0.1",
|
||||||
|
"ecdsa-sig-formatter": "1.0.11",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jws": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"jwa": "^2.0.1",
|
||||||
|
"safe-buffer": "^5.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/kdbush": {
|
"node_modules/kdbush": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz",
|
||||||
@@ -9814,6 +9899,12 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minimalistic-assert": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||||
@@ -10129,6 +10220,15 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/nodemailer": {
|
||||||
|
"version": "8.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz",
|
||||||
|
"integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/normalize-path": {
|
"node_modules/normalize-path": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
|
||||||
@@ -11338,6 +11438,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safer-buffer": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/sax": {
|
"node_modules/sax": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
@@ -13912,6 +14018,25 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-push": {
|
||||||
|
"version": "3.6.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||||
|
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||||
|
"license": "MPL-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"asn1.js": "^5.3.0",
|
||||||
|
"http_ece": "1.2.0",
|
||||||
|
"https-proxy-agent": "^7.0.0",
|
||||||
|
"jws": "^4.0.0",
|
||||||
|
"minimist": "^1.2.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"web-push": "src/cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.7.0",
|
"version": "0.8.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -24,7 +24,8 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"screenshots": "tsx scripts/screenshots.ts",
|
||||||
"icons": "tsx scripts/icons.ts"
|
"icons": "tsx scripts/icons.ts",
|
||||||
|
"vapid": "tsx scripts/vapid.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.16.2",
|
"@prisma/client": "^6.16.2",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"next": "^15.5.4",
|
"next": "^15.5.4",
|
||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"nodemailer": "^8.0.11",
|
||||||
"papaparse": "^5.7.0",
|
"papaparse": "^5.7.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -68,6 +70,7 @@
|
|||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"superjson": "^2.2.6",
|
"superjson": "^2.2.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -76,10 +79,12 @@
|
|||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/node": "^22.18.6",
|
"@types/node": "^22.18.6",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
"@types/papaparse": "^5.5.2",
|
"@types/papaparse": "^5.5.2",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.13",
|
"@types/react": "^19.1.13",
|
||||||
"@types/react-dom": "^19.1.9",
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
"@vitejs/plugin-react": "^5.0.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.25.10",
|
"esbuild": "^0.25.10",
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
-- Phase 7 — Benachrichtigungen und Termine
|
||||||
|
--
|
||||||
|
-- Von Prisma erzeugt, danach von Hand um die DropIndex-Anweisungen erleichtert
|
||||||
|
-- (siehe Kopf der Migration 20260903190219_atemschutz_fwdv7). Rechte,
|
||||||
|
-- Mandantentrennung und die Regeln fuer Termine stehen in der Migration
|
||||||
|
-- daneben.
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "EreignisTyp" AS ENUM ('PRUEFUNG_FAELLIG', 'MANGEL_GEMELDET', 'QUALIFIKATION_LAEUFT_AB', 'TERMIN_NEU', 'TERMIN_ERINNERUNG', 'TERMIN_ABGESAGT', 'EINSATZBERICHT_FREIGEGEBEN');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Kanal" AS ENUM ('MAIL', 'TELEGRAM', 'NTFY', 'WEBPUSH');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "TerminArt" AS ENUM ('DIENST', 'UEBUNG', 'LEHRGANG', 'ARBEITSDIENST', 'VERANSTALTUNG', 'SITZUNG', 'SONSTIGES');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Serienmuster" AS ENUM ('KEINE', 'WOECHENTLICH', 'ZWEIWOECHENTLICH', 'MONATLICH', 'JAEHRLICH');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Teilnahmestand" AS ENUM ('ZUGESAGT', 'ABGESAGT', 'WARTELISTE');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Zustellstand" AS ENUM ('WARTET', 'GESENDET', 'FEHLGESCHLAGEN', 'UEBERSPRUNGEN');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "benachrichtigungsvorlage" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"ereignis" "EreignisTyp" NOT NULL,
|
||||||
|
"kanal" "Kanal" NOT NULL,
|
||||||
|
"betreff" VARCHAR(200),
|
||||||
|
"text" VARCHAR(2000) NOT NULL,
|
||||||
|
"rollen" "RolleSchluessel"[],
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "benachrichtigungsvorlage_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "zustellkanal" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"kanal" "Kanal" NOT NULL,
|
||||||
|
"ziel" VARCHAR(500) NOT NULL,
|
||||||
|
"p256dh" VARCHAR(200),
|
||||||
|
"auth" VARCHAR(200),
|
||||||
|
"ereignisse" "EreignisTyp"[],
|
||||||
|
"bezeichnung" VARCHAR(120),
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"letzteZustellung" TIMESTAMPTZ(3),
|
||||||
|
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "zustellkanal_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "benachrichtigung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"ereignis" "EreignisTyp" NOT NULL,
|
||||||
|
"kanal" "Kanal" NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"betreff" VARCHAR(200),
|
||||||
|
"text" VARCHAR(2000) NOT NULL,
|
||||||
|
"ziel" VARCHAR(500) NOT NULL,
|
||||||
|
"entitaet" VARCHAR(80),
|
||||||
|
"entitaetId" UUID,
|
||||||
|
"stand" "Zustellstand" NOT NULL DEFAULT 'WARTET',
|
||||||
|
"versuche" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"gesendetAm" TIMESTAMPTZ(3),
|
||||||
|
"fehler" VARCHAR(500),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "benachrichtigung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "termin" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"art" "TerminArt" NOT NULL DEFAULT 'DIENST',
|
||||||
|
"titel" VARCHAR(200) NOT NULL,
|
||||||
|
"beschreibung" VARCHAR(4000),
|
||||||
|
"ort" VARCHAR(200),
|
||||||
|
"beginn" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"ende" TIMESTAMPTZ(3),
|
||||||
|
"ganztaegig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"leiterId" UUID,
|
||||||
|
"teilnehmerLimit" INTEGER,
|
||||||
|
"anmeldeschluss" TIMESTAMPTZ(3),
|
||||||
|
"anmeldungNoetig" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"serieId" UUID,
|
||||||
|
"serienmuster" "Serienmuster" NOT NULL DEFAULT 'KEINE',
|
||||||
|
"abgesagtAm" TIMESTAMPTZ(3),
|
||||||
|
"absageGrund" VARCHAR(500),
|
||||||
|
"erinnertAm" TIMESTAMPTZ(3),
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
"geloeschtAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "termin_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "terminanmeldung" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"terminId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"stand" "Teilnahmestand" NOT NULL DEFAULT 'ZUGESAGT',
|
||||||
|
"grund" VARCHAR(300),
|
||||||
|
"anwesend" BOOLEAN,
|
||||||
|
"gemeldetAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "terminanmeldung_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "kalenderzugang" (
|
||||||
|
"id" UUID NOT NULL,
|
||||||
|
"organisationId" UUID NOT NULL,
|
||||||
|
"personId" UUID NOT NULL,
|
||||||
|
"bezeichnung" VARCHAR(120) NOT NULL,
|
||||||
|
"tokenHash" VARCHAR(64) NOT NULL,
|
||||||
|
"nurEigene" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"aktiv" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"letzterZugriff" TIMESTAMPTZ(3),
|
||||||
|
"zugriffe" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"widerrufenAm" TIMESTAMPTZ(3),
|
||||||
|
|
||||||
|
CONSTRAINT "kalenderzugang_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "benachrichtigungsvorlage_organisationId_ereignis_kanal_key" ON "benachrichtigungsvorlage"("organisationId", "ereignis", "kanal");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "zustellkanal_organisationId_kanal_idx" ON "zustellkanal"("organisationId", "kanal");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "zustellkanal_personId_kanal_ziel_key" ON "zustellkanal"("personId", "kanal", "ziel");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "benachrichtigung_organisationId_stand_idx" ON "benachrichtigung"("organisationId", "stand");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "benachrichtigung_organisationId_personId_erstelltAm_idx" ON "benachrichtigung"("organisationId", "personId", "erstelltAm" DESC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_organisationId_beginn_idx" ON "termin"("organisationId", "beginn");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "termin_organisationId_serieId_idx" ON "termin"("organisationId", "serieId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "terminanmeldung_organisationId_personId_idx" ON "terminanmeldung"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "terminanmeldung_terminId_personId_key" ON "terminanmeldung"("terminId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "kalenderzugang_organisationId_personId_idx" ON "kalenderzugang"("organisationId", "personId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "kalenderzugang_tokenHash_key" ON "kalenderzugang"("tokenHash");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "benachrichtigungsvorlage" ADD CONSTRAINT "benachrichtigungsvorlage_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zustellkanal" ADD CONSTRAINT "zustellkanal_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "zustellkanal" ADD CONSTRAINT "zustellkanal_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_leiterId_fkey" FOREIGN KEY ("leiterId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_terminId_fkey" FOREIGN KEY ("terminId") REFERENCES "termin"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "terminanmeldung" ADD CONSTRAINT "terminanmeldung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kalenderzugang" ADD CONSTRAINT "kalenderzugang_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "kalenderzugang" ADD CONSTRAINT "kalenderzugang_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Phase 7: Mandantentrennung und die Regeln fuer Termine und Zustellung.
|
||||||
|
--
|
||||||
|
-- 1. Row Level Security fuer die sechs neuen Tabellen.
|
||||||
|
--
|
||||||
|
-- 2. Bedingungen, die Unmoegliches abweisen: ein Termin, der endet, bevor er
|
||||||
|
-- beginnt; ein Teilnehmerlimit von null; ein Anmeldeschluss nach dem
|
||||||
|
-- Termin.
|
||||||
|
--
|
||||||
|
-- 3. Teilindizes fuer die beiden Abfragen, die staendig laufen: was steht an
|
||||||
|
-- (kommende Termine) und was ist noch zu verschicken (offene
|
||||||
|
-- Benachrichtigungen).
|
||||||
|
--
|
||||||
|
-- 4. Ein zugestellter Eintrag wird nicht geloescht. Er ist der Nachweis
|
||||||
|
-- dafuer, wer wann worueber informiert wurde — und wer nicht.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Row Level Security
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
tabelle text;
|
||||||
|
BEGIN
|
||||||
|
FOREACH tabelle IN ARRAY ARRAY[
|
||||||
|
'benachrichtigungsvorlage',
|
||||||
|
'zustellkanal',
|
||||||
|
'benachrichtigung',
|
||||||
|
'termin',
|
||||||
|
'terminanmeldung',
|
||||||
|
'kalenderzugang'
|
||||||
|
]
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
|
||||||
|
EXECUTE format(
|
||||||
|
'CREATE POLICY %I ON %I FOR ALL '
|
||||||
|
'USING ("organisationId" = aktuelle_organisation()) '
|
||||||
|
'WITH CHECK ("organisationId" = aktuelle_organisation())',
|
||||||
|
tabelle || '_mandant', tabelle
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Was es nicht geben darf
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_ende_nach_beginn" CHECK (
|
||||||
|
"ende" IS NULL OR "ende" >= "beginn"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_limit_sinnvoll" CHECK (
|
||||||
|
"teilnehmerLimit" IS NULL OR "teilnehmerLimit" > 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Ein Anmeldeschluss nach dem Termin ist keiner.
|
||||||
|
ALTER TABLE "termin" ADD CONSTRAINT "termin_anmeldeschluss_vorher" CHECK (
|
||||||
|
"anmeldeschluss" IS NULL OR "anmeldeschluss" <= "beginn"
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE "benachrichtigung" ADD CONSTRAINT "benachrichtigung_versuche_plausibel" CHECK (
|
||||||
|
"versuche" >= 0 AND "versuche" <= 100
|
||||||
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Teilindizes fuer den laufenden Betrieb
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Was noch raus muss. Die Tabelle waechst ueber Jahre, die Warteschlange ist
|
||||||
|
-- immer kurz.
|
||||||
|
CREATE INDEX "benachrichtigung_offen" ON "benachrichtigung" ("organisationId", "erstelltAm")
|
||||||
|
WHERE "stand" = 'WARTET';
|
||||||
|
|
||||||
|
-- Was ansteht. Abgesagte und geloeschte Termine stehen im Kalender nicht mehr.
|
||||||
|
CREATE INDEX "termin_kommend" ON "termin" ("organisationId", "beginn")
|
||||||
|
WHERE "geloeschtAm" IS NULL AND "abgesagtAm" IS NULL;
|
||||||
|
|
||||||
|
-- Wer sich zu was gemeldet hat — fuer die Anwesenheitsliste.
|
||||||
|
CREATE INDEX "terminanmeldung_zugesagt" ON "terminanmeldung" ("terminId")
|
||||||
|
WHERE "stand" = 'ZUGESAGT';
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Volltextsuche ueber Termine
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE INDEX "termin_suche_titel" ON "termin" USING GIN ("titel" gin_trgm_ops);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Rechte fuer die Anwendungsrolle
|
||||||
|
--
|
||||||
|
-- Eine zugestellte Benachrichtigung wird nicht geloescht: sie ist der Nachweis
|
||||||
|
-- dafuer, wer wann worueber informiert wurde. Geaendert werden darf sie, denn
|
||||||
|
-- der Hintergrundprozess traegt Zustellstand und Fehlversuche nach.
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (SELECT FROM pg_roles WHERE rolname = 'florian_app') THEN
|
||||||
|
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
|
||||||
|
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
|
||||||
|
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
|
||||||
|
|
||||||
|
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
|
||||||
|
ELSE
|
||||||
|
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
@@ -217,6 +217,12 @@ model Organisation {
|
|||||||
lageelemente Lageelement[]
|
lageelemente Lageelement[]
|
||||||
wasserfoerderungen Wasserfoerderung[]
|
wasserfoerderungen Wasserfoerderung[]
|
||||||
foerderpunkte Foerderpunkt[]
|
foerderpunkte Foerderpunkt[]
|
||||||
|
benachrichtigungsvorlages Benachrichtigungsvorlage[]
|
||||||
|
zustellkanals Zustellkanal[]
|
||||||
|
benachrichtigungs Benachrichtigung[]
|
||||||
|
termins Termin[]
|
||||||
|
terminanmeldungs Terminanmeldung[]
|
||||||
|
kalenderzugangs Kalenderzugang[]
|
||||||
|
|
||||||
@@index([aktiv])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
@@ -324,6 +330,13 @@ model Person {
|
|||||||
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
gezeichneteLageelemente Lageelement[] @relation("LageelementZeichner")
|
||||||
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
geplanteFoerderstrecken Wasserfoerderung[] @relation("WasserfoerderungPlaner")
|
||||||
|
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
|
benachrichtigungen Benachrichtigung[] @relation("BenachrichtigungEmpfaenger")
|
||||||
|
zustellkanaele Zustellkanal[] @relation("ZustellkanalPerson")
|
||||||
|
geleiteteTermine Termin[] @relation("TerminLeitung")
|
||||||
|
terminanmeldungen Terminanmeldung[] @relation("TerminanmeldungPerson")
|
||||||
|
kalenderzugaenge Kalenderzugang[] @relation("KalenderzugangPerson")
|
||||||
|
|
||||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
/// Person kann in zwei Wehren Mitglied sein.
|
||||||
@@unique([organisationId, email])
|
@@unique([organisationId, email])
|
||||||
@@ -2964,3 +2977,302 @@ model Foerderpunkt {
|
|||||||
@@index([organisationId, wasserfoerderungId])
|
@@index([organisationId, wasserfoerderungId])
|
||||||
@@map("foerderpunkt")
|
@@map("foerderpunkt")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
|
//
|
||||||
|
// Zwei Themen, die zusammengehören: ein Termin ist der häufigste Anlass für
|
||||||
|
// eine Benachrichtigung, und eine Benachrichtigung ohne Termin dahinter ist
|
||||||
|
// meistens eine Frist.
|
||||||
|
//
|
||||||
|
// Der Aufbau folgt einer Regel: **niemand wird ungefragt benachrichtigt.**
|
||||||
|
// Jede Person entscheidet je Ereignis und Kanal selbst. Die Wehr legt fest,
|
||||||
|
// was es überhaupt gibt (Vorlagen) und wen es betrifft (Rollen) — ob
|
||||||
|
// zugestellt wird, entscheidet die Person.
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/// Wofür es eine Benachrichtigung geben kann.
|
||||||
|
enum EreignisTyp {
|
||||||
|
PRUEFUNG_FAELLIG
|
||||||
|
MANGEL_GEMELDET
|
||||||
|
QUALIFIKATION_LAEUFT_AB
|
||||||
|
TERMIN_NEU
|
||||||
|
TERMIN_ERINNERUNG
|
||||||
|
TERMIN_ABGESAGT
|
||||||
|
EINSATZBERICHT_FREIGEGEBEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie zugestellt wird.
|
||||||
|
enum Kanal {
|
||||||
|
/// SMTP über den eigenen Mailserver.
|
||||||
|
MAIL
|
||||||
|
/// Telegram-Bot. Externer Dienst — nur Hinweise ohne Personenbezug.
|
||||||
|
TELEGRAM
|
||||||
|
/// ntfy, selbst gehostet. Der einzige Push-Weg ohne fremden Server.
|
||||||
|
NTFY
|
||||||
|
/// Web-Push über den Browser. Braucht VAPID-Schlüssel.
|
||||||
|
WEBPUSH
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was für ein Termin.
|
||||||
|
enum TerminArt {
|
||||||
|
DIENST
|
||||||
|
UEBUNG
|
||||||
|
LEHRGANG
|
||||||
|
ARBEITSDIENST
|
||||||
|
VERANSTALTUNG
|
||||||
|
SITZUNG
|
||||||
|
SONSTIGES
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie oft ein Termin wiederkehrt.
|
||||||
|
enum Serienmuster {
|
||||||
|
KEINE
|
||||||
|
WOECHENTLICH
|
||||||
|
ZWEIWOECHENTLICH
|
||||||
|
MONATLICH
|
||||||
|
JAEHRLICH
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Was jemand zu einem Termin gesagt hat.
|
||||||
|
enum Teilnahmestand {
|
||||||
|
ZUGESAGT
|
||||||
|
ABGESAGT
|
||||||
|
/// Angemeldet, aber die Liste war voll — rückt nach, wenn jemand absagt.
|
||||||
|
WARTELISTE
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wie es einer Benachrichtigung ergangen ist.
|
||||||
|
enum Zustellstand {
|
||||||
|
WARTET
|
||||||
|
GESENDET
|
||||||
|
FEHLGESCHLAGEN
|
||||||
|
/// Niemand hat diesen Kanal abonniert oder der Kanal ist aus.
|
||||||
|
UEBERSPRUNGEN
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine Vorlage je Ereignis und Kanal.
|
||||||
|
///
|
||||||
|
/// Der Text enthält Platzhalter in geschweiften Klammern: `{titel}`, `{datum}`,
|
||||||
|
/// `{wehr}`. Welche es gibt, steht in lib/benachrichtigung/vorlagen.ts — und
|
||||||
|
/// die Oberfläche zeigt sie beim Bearbeiten an, damit niemand raten muss.
|
||||||
|
///
|
||||||
|
/// Fehlt eine Vorlage, greift der eingebaute Standardtext. Eine Wehr, die
|
||||||
|
/// nichts einstellt, bekommt trotzdem brauchbare Nachrichten.
|
||||||
|
model Benachrichtigungsvorlage {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
|
||||||
|
ereignis EreignisTyp
|
||||||
|
kanal Kanal
|
||||||
|
|
||||||
|
/// Nur für E-Mail. Push-Dienste kennen keinen Betreff.
|
||||||
|
betreff String? @db.VarChar(200)
|
||||||
|
text String @db.VarChar(2000)
|
||||||
|
|
||||||
|
/// Wen es angeht. Leer heißt: alle, die es abonniert haben.
|
||||||
|
rollen RolleSchluessel[]
|
||||||
|
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([organisationId, ereignis, kanal])
|
||||||
|
@@map("benachrichtigungsvorlage")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wohin eine Person benachrichtigt werden will.
|
||||||
|
///
|
||||||
|
/// Ein Datensatz je Person und Kanal, mit der Adresse dieses Kanals: die
|
||||||
|
/// Chat-Kennung bei Telegram, das Thema bei ntfy, das Abonnement des Browsers
|
||||||
|
/// bei Web-Push. Die E-Mail steht schon an der Person.
|
||||||
|
///
|
||||||
|
/// `ereignisse` sagt, wofür dieser Kanal benutzt wird. Leer heißt: für nichts —
|
||||||
|
/// **niemand wird ungefragt benachrichtigt.**
|
||||||
|
model Zustellkanal {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
personId String @db.Uuid
|
||||||
|
|
||||||
|
kanal Kanal
|
||||||
|
|
||||||
|
/// Chat-Kennung, ntfy-Thema oder die Endpunktadresse des Browsers.
|
||||||
|
ziel String @db.VarChar(500)
|
||||||
|
/// Web-Push braucht zwei Schlüssel dazu.
|
||||||
|
p256dh String? @db.VarChar(200)
|
||||||
|
auth String? @db.VarChar(200)
|
||||||
|
|
||||||
|
/// Wofür dieser Kanal benutzt wird.
|
||||||
|
ereignisse EreignisTyp[]
|
||||||
|
|
||||||
|
bezeichnung String? @db.VarChar(120)
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
|
/// Wann zuletzt etwas darüber ankam — ein toter Kanal fällt sonst nicht auf.
|
||||||
|
letzteZustellung DateTime? @db.Timestamptz(3)
|
||||||
|
fehlversuche Int @default(0)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
person Person @relation("ZustellkanalPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([personId, kanal, ziel])
|
||||||
|
@@index([organisationId, kanal])
|
||||||
|
@@map("zustellkanal")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Eine verschickte oder zu verschickende Benachrichtigung.
|
||||||
|
///
|
||||||
|
/// Sie steht **vor** dem Versand in der Tabelle, nicht danach: der
|
||||||
|
/// Hintergrundprozess arbeitet sie ab und vermerkt, wie es ausging. Damit ist
|
||||||
|
/// nachvollziehbar, wer wann worüber informiert wurde — und wer nicht, weil
|
||||||
|
/// sein Kanal nicht erreichbar war.
|
||||||
|
model Benachrichtigung {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
|
||||||
|
ereignis EreignisTyp
|
||||||
|
kanal Kanal
|
||||||
|
personId String @db.Uuid
|
||||||
|
|
||||||
|
betreff String? @db.VarChar(200)
|
||||||
|
text String @db.VarChar(2000)
|
||||||
|
/// Wohin es ging — zum Nachvollziehen, auch wenn der Kanal später wegfällt.
|
||||||
|
ziel String @db.VarChar(500)
|
||||||
|
|
||||||
|
/// Worum es ging: "Wartungstermin", "Termin", "Mangel" …
|
||||||
|
entitaet String? @db.VarChar(80)
|
||||||
|
entitaetId String? @db.Uuid
|
||||||
|
|
||||||
|
stand Zustellstand @default(WARTET)
|
||||||
|
versuche Int @default(0)
|
||||||
|
gesendetAm DateTime? @db.Timestamptz(3)
|
||||||
|
fehler String? @db.VarChar(500)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
person Person @relation("BenachrichtigungEmpfaenger", fields: [personId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([organisationId, stand])
|
||||||
|
@@index([organisationId, personId, erstelltAm(sort: Desc)])
|
||||||
|
@@map("benachrichtigung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Termin im Kalender der Wehr.
|
||||||
|
///
|
||||||
|
/// Serientermine werden **ausgeschrieben**, nicht gerechnet: jeder Abend ist
|
||||||
|
/// ein eigener Datensatz mit Verweis auf den ersten. Sonst ließe sich ein
|
||||||
|
/// einzelner Abend nicht absagen, keine Teilnehmerliste führen und keine
|
||||||
|
/// Anwesenheit abhaken — und genau das ist der Alltag.
|
||||||
|
model Termin {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
|
||||||
|
art TerminArt @default(DIENST)
|
||||||
|
titel String @db.VarChar(200)
|
||||||
|
|
||||||
|
beschreibung String? @db.VarChar(4000)
|
||||||
|
ort String? @db.VarChar(200)
|
||||||
|
|
||||||
|
beginn DateTime @db.Timestamptz(3)
|
||||||
|
ende DateTime? @db.Timestamptz(3)
|
||||||
|
ganztaegig Boolean @default(false)
|
||||||
|
|
||||||
|
/// Wer ihn leitet.
|
||||||
|
leiterId String? @db.Uuid
|
||||||
|
|
||||||
|
/// Ab wie vielen Zusagen die Warteliste beginnt. Null heißt: unbegrenzt.
|
||||||
|
teilnehmerLimit Int?
|
||||||
|
/// Bis wann man sich anmelden kann.
|
||||||
|
anmeldeschluss DateTime? @db.Timestamptz(3)
|
||||||
|
/// Erwartet die Wehr eine Rückmeldung? Sonst ist es nur ein Eintrag.
|
||||||
|
anmeldungNoetig Boolean @default(false)
|
||||||
|
|
||||||
|
/// Serie: der erste Termin trägt das Muster, alle tragen dieselbe Serienkennung.
|
||||||
|
serieId String? @db.Uuid
|
||||||
|
serienmuster Serienmuster @default(KEINE)
|
||||||
|
|
||||||
|
/// Abgesagt statt gelöscht — wer schon zugesagt hat, soll den Grund sehen.
|
||||||
|
abgesagtAm DateTime? @db.Timestamptz(3)
|
||||||
|
absageGrund String? @db.VarChar(500)
|
||||||
|
|
||||||
|
/// Wann erinnert wurde, damit es nicht zweimal passiert.
|
||||||
|
erinnertAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
leiter Person? @relation("TerminLeitung", fields: [leiterId], references: [id], onDelete: SetNull)
|
||||||
|
anmeldungen Terminanmeldung[]
|
||||||
|
|
||||||
|
@@index([organisationId, beginn])
|
||||||
|
@@index([organisationId, serieId])
|
||||||
|
@@map("termin")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zu- oder Absage einer Person zu einem Termin.
|
||||||
|
model Terminanmeldung {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
terminId String @db.Uuid
|
||||||
|
personId String @db.Uuid
|
||||||
|
|
||||||
|
stand Teilnahmestand @default(ZUGESAGT)
|
||||||
|
/// Bei einer Absage: warum. Freiwillig, aber hilfreich für die Planung.
|
||||||
|
grund String? @db.VarChar(300)
|
||||||
|
|
||||||
|
/// Beim Abhaken der Anwesenheitsliste gesetzt.
|
||||||
|
anwesend Boolean?
|
||||||
|
|
||||||
|
gemeldetAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
termin Termin @relation(fields: [terminId], references: [id], onDelete: Cascade)
|
||||||
|
person Person @relation("TerminanmeldungPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([terminId, personId])
|
||||||
|
@@index([organisationId, personId])
|
||||||
|
@@map("terminanmeldung")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ein Kalenderabonnement je Person.
|
||||||
|
///
|
||||||
|
/// Der Kalender im Handy fragt die Adresse regelmäßig ab — ohne Anmeldung,
|
||||||
|
/// also mit einem Geheimnis in der Adresse. Wie beim Einsatzportal aus Phase 3
|
||||||
|
/// liegt davon nur der Hash in der Datenbank: wer die Tabelle liest, bekommt
|
||||||
|
/// keinen Zugang.
|
||||||
|
model Kalenderzugang {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
organisationId String @db.Uuid
|
||||||
|
personId String @db.Uuid
|
||||||
|
|
||||||
|
bezeichnung String @db.VarChar(120)
|
||||||
|
/// SHA-256 des Tokens. Das Token selbst sieht man genau einmal.
|
||||||
|
tokenHash String @db.VarChar(64)
|
||||||
|
|
||||||
|
/// Nur eigene Termine oder alle der Wehr?
|
||||||
|
nurEigene Boolean @default(false)
|
||||||
|
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
letzterZugriff DateTime? @db.Timestamptz(3)
|
||||||
|
zugriffe Int @default(0)
|
||||||
|
|
||||||
|
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
|
||||||
|
widerrufenAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
person Person @relation("KalenderzugangPerson", fields: [personId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([tokenHash])
|
||||||
|
@@index([organisationId, personId])
|
||||||
|
@@map("kalenderzugang")
|
||||||
|
}
|
||||||
|
|||||||
@@ -15,7 +15,10 @@
|
|||||||
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
* Belastungsübungen der Atemschutzgeräteträger und eine abgeschlossene
|
||||||
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
* Überwachung mit vollständigem Protokoll. Ab Phase 6 dazu: Koordinaten für
|
||||||
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
* die Standorte, 24 Löschwasserstellen im Ausrückegebiet und drei Objekte mit
|
||||||
* Feuerwehrplan.
|
* Feuerwehrplan. Ab Phase 7 dazu: der Jahresplan — Übungsabend als Serie,
|
||||||
|
* Lehrgang mit Warteliste, Arbeitsdienst, Jahreshauptversammlung, ein
|
||||||
|
* abgesagter Termin und eine abgehakte Anwesenheitsliste. Bewusst OHNE
|
||||||
|
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||||||
*
|
*
|
||||||
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -57,13 +60,17 @@ import {
|
|||||||
HydrantStatus,
|
HydrantStatus,
|
||||||
HydrantTyp,
|
HydrantTyp,
|
||||||
ObjektKategorie,
|
ObjektKategorie,
|
||||||
|
Serienmuster,
|
||||||
StandortArt,
|
StandortArt,
|
||||||
|
Teilnahmestand,
|
||||||
|
TerminArt,
|
||||||
TruppStatus,
|
TruppStatus,
|
||||||
UebungsArt,
|
UebungsArt,
|
||||||
UebungsartAtemschutz,
|
UebungsartAtemschutz,
|
||||||
WaescheStatus,
|
WaescheStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { serientermine } from "../src/lib/termin/serie";
|
||||||
|
|
||||||
const prisma = new PrismaClient({
|
const prisma = new PrismaClient({
|
||||||
datasources: {
|
datasources: {
|
||||||
@@ -2754,6 +2761,244 @@ async function kartendatenAnlegen(organisationId: string) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
|
//
|
||||||
|
// Der Jahresplan einer Wehr, wie er wirklich aussieht: der Übungsabend jeden
|
||||||
|
// zweiten Dienstag, ein Lehrgang mit begrenzten Plätzen (samt Warteliste, weil
|
||||||
|
// die immer entsteht), der Arbeitsdienst im Frühjahr, die
|
||||||
|
// Jahreshauptversammlung — und ein Termin, der abgesagt wurde, damit man sieht,
|
||||||
|
// wie das aussieht.
|
||||||
|
//
|
||||||
|
// Bewusst KEINE Zustellkanäle im Seed: niemand wird ungefragt benachrichtigt,
|
||||||
|
// und das gilt auch für Beispieldaten. Wer Push ausprobieren will, richtet ihn
|
||||||
|
// unter /benachrichtigungen selbst ein — genau so, wie es später jedes
|
||||||
|
// Mitglied tut.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function termineAnlegen(organisationId: string): Promise<void> {
|
||||||
|
const vorhanden = await prisma.termin.count({ where: { organisationId } });
|
||||||
|
if (vorhanden > 0) {
|
||||||
|
log("Termine bereits vorhanden — übersprungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date();
|
||||||
|
start.setUTCHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
/** Ein Zeitpunkt in n Tagen, zur angegebenen Stunde (Ortszeit-nah, UTC+1). */
|
||||||
|
function inTagen(tage: number, stunde = 19, minute = 0): Date {
|
||||||
|
const wann = new Date(start);
|
||||||
|
wann.setUTCDate(wann.getUTCDate() + tage);
|
||||||
|
wann.setUTCHours(stunde - 1, minute, 0, 0);
|
||||||
|
return wann;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personen = await prisma.person.findMany({
|
||||||
|
where: { organisationId, aktiv: true, geloeschtAm: null },
|
||||||
|
orderBy: { mitgliedsnummer: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const kommandant = await prisma.person.findFirst({
|
||||||
|
where: {
|
||||||
|
organisationId,
|
||||||
|
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Der Übungsabend als Serie -------------------------------------------
|
||||||
|
//
|
||||||
|
// Alle zwei Wochen, ein halbes Jahr im Voraus. Ausgeschrieben als einzelne
|
||||||
|
// Termine, nicht als Regel — warum, steht in lib/termin/serie.ts.
|
||||||
|
const ersterAbend = await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.UEBUNG,
|
||||||
|
titel: "Übungsabend",
|
||||||
|
beschreibung:
|
||||||
|
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
|
||||||
|
ort: "Gerätehaus Musterhausen",
|
||||||
|
beginn: inTagen(4),
|
||||||
|
ende: inTagen(4, 21),
|
||||||
|
leiterId: kommandant?.id ?? null,
|
||||||
|
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.termin.update({
|
||||||
|
where: { id: ersterAbend.id },
|
||||||
|
data: { serieId: ersterAbend.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const weitereAbende = serientermine({
|
||||||
|
beginn: ersterAbend.beginn,
|
||||||
|
muster: Serienmuster.ZWEIWOECHENTLICH,
|
||||||
|
bis: inTagen(4 + 182),
|
||||||
|
}).slice(1);
|
||||||
|
|
||||||
|
await prisma.termin.createMany({
|
||||||
|
data: weitereAbende.map((zeitpunkt) => ({
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.UEBUNG,
|
||||||
|
titel: "Übungsabend",
|
||||||
|
beschreibung:
|
||||||
|
"Regelmäßiger Übungsdienst der aktiven Mannschaft. Einsatzkleidung mitbringen.",
|
||||||
|
ort: "Gerätehaus Musterhausen",
|
||||||
|
beginn: zeitpunkt,
|
||||||
|
ende: new Date(zeitpunkt.getTime() + 2 * 3_600_000),
|
||||||
|
leiterId: kommandant?.id ?? null,
|
||||||
|
serieId: ersterAbend.id,
|
||||||
|
serienmuster: Serienmuster.ZWEIWOECHENTLICH,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Lehrgang mit Platzbegrenzung ----------------------------------------
|
||||||
|
//
|
||||||
|
// Acht Plätze, elf Anmeldungen. Die Warteliste ist der Regelfall, nicht der
|
||||||
|
// Sonderfall — deshalb steht sie schon im Seed.
|
||||||
|
const lehrgang = await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.LEHRGANG,
|
||||||
|
titel: "Maschinistenlehrgang Teil 1",
|
||||||
|
beschreibung:
|
||||||
|
"Pumpenkunde, Wasserförderung, Fahrzeugtechnik. Voraussetzung für die Ausbildung zum Maschinisten.",
|
||||||
|
ort: "Feuerwehrschule Landkreis",
|
||||||
|
beginn: inTagen(21, 8),
|
||||||
|
ende: inTagen(21, 17),
|
||||||
|
leiterId: kommandant?.id ?? null,
|
||||||
|
anmeldungNoetig: true,
|
||||||
|
teilnehmerLimit: 8,
|
||||||
|
anmeldeschluss: inTagen(14, 23, 59),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const lehrgangsplaetze = personen.slice(0, 11);
|
||||||
|
|
||||||
|
await prisma.terminanmeldung.createMany({
|
||||||
|
data: lehrgangsplaetze.map((person, stelle) => ({
|
||||||
|
organisationId,
|
||||||
|
terminId: lehrgang.id,
|
||||||
|
personId: person.id,
|
||||||
|
// Die ersten acht haben einen Platz, der Rest steht in der Reihenfolge
|
||||||
|
// der Anmeldung auf der Warteliste.
|
||||||
|
stand: stelle < 8 ? Teilnahmestand.ZUGESAGT : Teilnahmestand.WARTELISTE,
|
||||||
|
gemeldetAm: new Date(Date.now() - (11 - stelle) * 3_600_000),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Arbeitsdienst --------------------------------------------------------
|
||||||
|
const arbeitsdienst = await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.ARBEITSDIENST,
|
||||||
|
titel: "Frühjahrsputz am Gerätehaus",
|
||||||
|
beschreibung:
|
||||||
|
"Fahrzeughalle, Schlauchturm und Hof. Arbeitskleidung, kein Atemschutz nötig. Brotzeit ist gestellt.",
|
||||||
|
ort: "Gerätehaus Musterhausen",
|
||||||
|
beginn: inTagen(11, 9),
|
||||||
|
ende: inTagen(11, 13),
|
||||||
|
anmeldungNoetig: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gemischte Rückmeldungen — mit Gründen, wie sie wirklich kommen.
|
||||||
|
const GRUENDE = ["Schicht", "Urlaub", "Kind krank", "auswärts"];
|
||||||
|
|
||||||
|
await prisma.terminanmeldung.createMany({
|
||||||
|
data: personen.slice(0, 12).map((person, stelle) => {
|
||||||
|
const abgesagtVon = stelle % 4 === 3;
|
||||||
|
return {
|
||||||
|
organisationId,
|
||||||
|
terminId: arbeitsdienst.id,
|
||||||
|
personId: person.id,
|
||||||
|
stand: abgesagtVon ? Teilnahmestand.ABGESAGT : Teilnahmestand.ZUGESAGT,
|
||||||
|
grund: abgesagtVon ? (GRUENDE[stelle % GRUENDE.length] ?? null) : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Jahreshauptversammlung ----------------------------------------------
|
||||||
|
await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.SITZUNG,
|
||||||
|
titel: "Jahreshauptversammlung",
|
||||||
|
beschreibung:
|
||||||
|
"Berichte des Kommandanten, des Kassiers und des Jugendwarts. Ehrungen, Neuwahl der Kassenprüfer.",
|
||||||
|
ort: "Gasthaus zur Post, Nebenzimmer",
|
||||||
|
beginn: inTagen(45, 19, 30),
|
||||||
|
ende: inTagen(45, 22),
|
||||||
|
leiterId: kommandant?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ein abgesagter Termin ------------------------------------------------
|
||||||
|
//
|
||||||
|
// Damit gleich sichtbar ist, wie eine Absage aussieht: durchgestrichen im
|
||||||
|
// Kalender, mit Grund — nicht verschwunden.
|
||||||
|
const abgesagt = await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.VERANSTALTUNG,
|
||||||
|
titel: "Waldfest der Feuerwehr",
|
||||||
|
beschreibung: "Aufbau ab 14 Uhr, Ausschank ab 17 Uhr.",
|
||||||
|
ort: "Festplatz am Mühlweg",
|
||||||
|
beginn: inTagen(30, 14),
|
||||||
|
ende: inTagen(30, 23),
|
||||||
|
abgesagtAm: new Date(),
|
||||||
|
absageGrund: "Dauerregen angekündigt, Festplatz nicht befahrbar. Neuer Termin folgt.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.terminanmeldung.createMany({
|
||||||
|
data: personen.slice(0, 6).map((person) => ({
|
||||||
|
organisationId,
|
||||||
|
terminId: abgesagt.id,
|
||||||
|
personId: person.id,
|
||||||
|
stand: Teilnahmestand.ZUGESAGT,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Ein vergangener Termin mit abgehakter Anwesenheit --------------------
|
||||||
|
//
|
||||||
|
// Der Nachweis, um den es am Ende geht: wer war da. Mit zweien, die zugesagt
|
||||||
|
// hatten und nicht kamen — das ist der Fall, für den die Trennung von Zusage
|
||||||
|
// und Anwesenheit überhaupt existiert.
|
||||||
|
const vergangen = await prisma.termin.create({
|
||||||
|
data: {
|
||||||
|
organisationId,
|
||||||
|
art: TerminArt.UEBUNG,
|
||||||
|
titel: "Übungsabend Löschangriff",
|
||||||
|
beschreibung: "Wasserentnahme aus offenem Gewässer, Löschangriff nach FwDV 3.",
|
||||||
|
ort: "Mühlweg, Saugstelle",
|
||||||
|
beginn: inTagen(-10),
|
||||||
|
ende: inTagen(-10, 21),
|
||||||
|
leiterId: kommandant?.id ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.terminanmeldung.createMany({
|
||||||
|
data: personen.slice(0, 14).map((person, stelle) => ({
|
||||||
|
organisationId,
|
||||||
|
terminId: vergangen.id,
|
||||||
|
personId: person.id,
|
||||||
|
stand: Teilnahmestand.ZUGESAGT,
|
||||||
|
anwesend: stelle !== 5 && stelle !== 9,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const gesamt = await prisma.termin.count({ where: { organisationId } });
|
||||||
|
|
||||||
|
log(
|
||||||
|
`Termine: ${gesamt} insgesamt (Übungsserie mit ${weitereAbende.length + 1} Abenden, ` +
|
||||||
|
`Lehrgang mit 8 Plätzen und ${lehrgangsplaetze.length - 8} auf der Warteliste, ` +
|
||||||
|
`ein abgesagter Termin, eine abgehakte Anwesenheitsliste)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// Ablauf
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -2794,6 +3039,9 @@ async function main(): Promise<void> {
|
|||||||
// Phase 6 — Kartenmodule
|
// Phase 6 — Kartenmodule
|
||||||
await kartendatenAnlegen(organisation.id);
|
await kartendatenAnlegen(organisation.id);
|
||||||
|
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
|
await termineAnlegen(organisation.id);
|
||||||
|
|
||||||
log("Fertig.");
|
log("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log("Anmeldung für den Erststart:");
|
log("Anmeldung für den Erststart:");
|
||||||
|
|||||||
@@ -161,3 +161,54 @@ self.addEventListener("message", (ereignis) => {
|
|||||||
ereignis.waitUntil(caches.delete(DATEN));
|
ereignis.waitUntil(caches.delete(DATEN));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web-Push (Phase 7).
|
||||||
|
*
|
||||||
|
* Ein Push erreicht den Browser auch dann, wenn FLORIAN nicht offen ist —
|
||||||
|
* deshalb muss der Service Worker ihn annehmen. Er tut zwei Dinge und sonst
|
||||||
|
* nichts: die Meldung anzeigen und beim Antippen die passende Seite öffnen.
|
||||||
|
*
|
||||||
|
* `tag` sorgt dafür, dass eine neue Meldung zum selben Ereignis die alte
|
||||||
|
* ersetzt statt sich daneben zu legen. Sonst stapeln sich nachts fünf
|
||||||
|
* Erinnerungen an denselben Termin.
|
||||||
|
*/
|
||||||
|
self.addEventListener("push", (ereignis) => {
|
||||||
|
if (!ereignis.data) return;
|
||||||
|
|
||||||
|
let inhalt = {};
|
||||||
|
try {
|
||||||
|
inhalt = ereignis.data.json();
|
||||||
|
} catch (fehler) {
|
||||||
|
inhalt = { titel: "FLORIAN", text: ereignis.data.text() };
|
||||||
|
}
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.registration.showNotification(inhalt.titel || "FLORIAN", {
|
||||||
|
body: inhalt.text || "",
|
||||||
|
icon: "/icons/florian-192.png",
|
||||||
|
badge: "/icons/florian-192.png",
|
||||||
|
tag: inhalt.kennzeichen || "florian",
|
||||||
|
renotify: Boolean(inhalt.kennzeichen),
|
||||||
|
data: { verweis: inhalt.verweis || "/" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("notificationclick", (ereignis) => {
|
||||||
|
ereignis.notification.close();
|
||||||
|
const ziel = (ereignis.notification.data && ereignis.notification.data.verweis) || "/";
|
||||||
|
|
||||||
|
ereignis.waitUntil(
|
||||||
|
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((fenster) => {
|
||||||
|
// Ein schon offenes FLORIAN in den Vordergrund holen, statt ein zweites
|
||||||
|
// aufzumachen — sonst hat man nach einer Woche zwölf Tabs.
|
||||||
|
for (const eintrag of fenster) {
|
||||||
|
if (eintrag.url.includes(new URL(ziel, self.location.origin).pathname)) {
|
||||||
|
return eintrag.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self.clients.openWindow(ziel);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -204,6 +204,32 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Wasserförderung über lange Wegstrecke",
|
beschreibung: "Wasserförderung über lange Wegstrecke",
|
||||||
angemeldet: true,
|
angemeldet: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Phase 7 — Benachrichtigungen und Termine
|
||||||
|
{
|
||||||
|
name: "37-termine",
|
||||||
|
pfad: "/termine",
|
||||||
|
beschreibung: "Terminkalender mit Rückmeldung",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "38-benachrichtigungen",
|
||||||
|
pfad: "/benachrichtigungen",
|
||||||
|
beschreibung: "Eigene Zustellwege und Kalenderabonnement",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "39-vorlagen",
|
||||||
|
pfad: "/benachrichtigungen/vorlagen",
|
||||||
|
beschreibung: "Textvorlagen mit Platzhaltern und Vorschau",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "40-dienstausweis",
|
||||||
|
pfad: "/ausweis",
|
||||||
|
beschreibung: "Digitaler Dienstausweis mit QR-Code",
|
||||||
|
angemeldet: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -298,15 +324,41 @@ async function fertigGeladen(seite: Page): Promise<void> {
|
|||||||
await seite.waitForTimeout(500);
|
await seite.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Anmelden — mit Wiederholung, weil der erste Versuch bei kaltem .next
|
||||||
|
* regelmaessig ins Leere geht.
|
||||||
|
*
|
||||||
|
* Der Grund ist keine Schwaeche der Anwendung, sondern der
|
||||||
|
* Entwicklungsserver: "networkidle" sagt, dass die Skripte geladen sind, nicht
|
||||||
|
* dass React sie schon ausgefuehrt hat. Wird davor geklickt, laeuft der
|
||||||
|
* onSubmit-Handler nicht, und das Formular schickt sich als gewoehnliches GET
|
||||||
|
* ab — erkennbar daran, dass Adresse und Passwort in der Adresszeile stehen.
|
||||||
|
*
|
||||||
|
* Statt blind eine Wartezeit einzubauen, wird das erkannt und der Versuch
|
||||||
|
* wiederholt. Beim zweiten Mal ist die Seite uebersetzt und hydriert.
|
||||||
|
*/
|
||||||
async function anmelden(seite: Page): Promise<void> {
|
async function anmelden(seite: Page): Promise<void> {
|
||||||
|
for (let versuch = 1; versuch <= 3; versuch += 1) {
|
||||||
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
await seite.goto(`${BASIS}/anmelden`, { waitUntil: "networkidle" });
|
||||||
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
await seite.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
|
||||||
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
await seite.getByLabel("Passwort").fill(ADMIN_PASSWORT);
|
||||||
await seite.getByRole("button", { name: "Anmelden" }).click();
|
await seite.getByRole("button", { name: "Anmelden" }).click();
|
||||||
// Grosszuegig: bei kaltem .next uebersetzt "next dev" die Route erst beim
|
|
||||||
// ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
|
try {
|
||||||
|
// Grosszuegig: bei kaltem .next uebersetzt "next dev" jede Route erst
|
||||||
|
// beim ersten Aufruf, und das dauert deutlich laenger als eine Anmeldung.
|
||||||
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
|
await seite.waitForURL(`${BASIS}/`, { timeout: 90_000 });
|
||||||
await seite.waitForLoadState("networkidle");
|
await seite.waitForLoadState("networkidle");
|
||||||
|
return;
|
||||||
|
} catch (fehler) {
|
||||||
|
const nichtHydriert = seite.url().includes("?email=");
|
||||||
|
if (versuch === 3 || !nichtHydriert) throw fehler;
|
||||||
|
|
||||||
|
console.warn(
|
||||||
|
`[screenshots] Anmeldung lief ins Leere (Seite war noch nicht hydriert) — Versuch ${versuch + 1}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import webpush from "web-push";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAPID-Schlüsselpaar für Web-Push erzeugen.
|
||||||
|
*
|
||||||
|
* npm run vapid
|
||||||
|
*
|
||||||
|
* Die Ausgabe kommt in die .env. Beide Schlüssel gehören ins Backup:
|
||||||
|
*
|
||||||
|
* * Der **öffentliche** wird dem Browser mitgegeben, wenn sich jemand für
|
||||||
|
* Push anmeldet. Wechselt er, sind alle bestehenden Abos wertlos — jeder
|
||||||
|
* müsste Push von Hand neu erlauben. Das merkt niemand von allein; die
|
||||||
|
* Meldungen bleiben einfach aus.
|
||||||
|
* * Der **private** signiert jede Nachricht an den Push-Dienst. Wer ihn hat,
|
||||||
|
* kann im Namen dieser Installation Meldungen schicken.
|
||||||
|
*
|
||||||
|
* Erzeugt wird lokal, ohne Netzzugriff — es ist reine Kryptografie.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const paar = webpush.generateVAPIDKeys();
|
||||||
|
|
||||||
|
console.log(`
|
||||||
|
Schlüsselpaar erzeugt. Diese Zeilen in die .env übernehmen:
|
||||||
|
|
||||||
|
WEBPUSH_ENABLED=true
|
||||||
|
WEBPUSH_PUBLIC_KEY=${paar.publicKey}
|
||||||
|
WEBPUSH_PRIVATE_KEY=${paar.privateKey}
|
||||||
|
WEBPUSH_KONTAKT=mailto:kommandant@example.org
|
||||||
|
|
||||||
|
Hinweis: Ein neuer öffentlicher Schlüssel macht alle bestehenden Push-Abos
|
||||||
|
wertlos. Beide Schlüssel gehören deshalb ins Backup — nicht ins Git.
|
||||||
|
`);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,23 +9,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
|
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||||
|
|
||||||
export const metadata = { title: "Mitglied" };
|
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.
|
* Mitgliedsdetail.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -14,23 +14,10 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
|
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
|
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||||
|
|
||||||
export const metadata = { title: "Mitglieder" };
|
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.
|
* Mitgliederliste, nach Abteilung gruppiert.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||