Phase 1: Stammdatenverwaltung (#1)

Standorte, Fahrzeuge mit Beladeordnung, Gerätetypen als Vorgabe, Geräte mit
QR-Etiketten, Qualifikationen mit Ablaufwarnung und der Import aus vorhandenen
Tabellen. Enthält zusätzlich die Planung der Kassenverwaltung als Phase 9.
This commit was merged in pull request #1.
This commit is contained in:
2026-09-03 06:39:50 +00:00
82 changed files with 11841 additions and 248 deletions
+80 -1
View File
@@ -8,7 +8,86 @@ Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht] ## [Unveröffentlicht]
Noch nichts. Nächster Schritt ist Phase 1 (Stammdatenverwaltung). Noch nichts. Nächster Schritt ist Phase 2 (Wartungs- und Prüfmanagement).
## [0.2.0] — 2026-09-03
Phase 1: Stammdaten. Ab hier führt die Wehr ihren Bestand in FLORIAN — mit
Standorten, Fahrzeugen samt Beladeordnung, Gerätetypen als Vorgabe und
Geräten, die per QR-Code am Regal auffindbar sind.
### Hinzugefügt
**Datenmodell**
- `Standort` (Gerätehaus, Lager, Außenstelle, Werkstatt) mit PostGIS-Punkt für
die Karte ab Phase 6.
- `Geraeteraum` — die Fächer am Fahrzeug (G1 bis G6, Heck, Dach,
Mannschaftsraum), in Beladereihenfolge sortierbar.
- `Geraetetyp` als Vorgabe für Prüfintervall, Prüfvorschrift und Lebensdauer.
Ein einzelnes Gerät darf davon abweichen; sonst gilt der Typ.
- `Qualifikationstyp` und `Qualifikation` mit Gültigkeitsdauer und eigener
Vorwarnzeit je Typ.
- `Fahrzeug` erweitert um Fahrgestellnummer, DIN-Typkurzzeichen, Erstzulassung,
Sitzplätze, Tankinhalt, Gesamtmasse und die UVV-Frist.
- `Geraet` erweitert um Gerätetyp, Lebensdauerkategorie, errechnetes
Lebensdauerende, Anschaffungspreis und die Lagerung.
**Regeln in der Datenbank**
- Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug
verlastet — eine Prüfbedingung setzt das durch, ein Trigger stellt zusätzlich
sicher, dass ein angegebener Geräteraum auch zum angegebenen Fahrzeug gehört.
- Row Level Security auf allen neuen Tabellen.
- Trigram-Indizes für die Volltextsuche über Geräte, Fahrzeuge und Personen.
**Funktionen**
- Geräteliste mit Volltextsuche, Statusfilter, serverseitiger Sortierung,
Blättern und Spaltenauswahl. Die Spaltenauswahl bleibt je Browser gespeichert.
- Massenbearbeitung: Status mehrerer Geräte auf einmal setzen, Geräte umlagern.
Jede Änderung erzeugt einen eigenen Protokolleintrag am jeweiligen Gerät.
- QR-Code je Gerät. Der Code zeigt auf `/g/<inventarnummer>` und führt nach der
Anmeldung direkt zur Gerätedetailseite.
- Etikettenbogen als PDF über Gotenberg: DIN A4 mit 65 Etiketten
(38,1 × 21,2 mm). Der Druck kann an einer beliebigen Position beginnen, damit
angebrochene Bögen weiterverwendet werden können.
- QR-Scan über die Kamera im Browser, mit Eingabefeld als Rückfallweg.
- Import aus CSV und Excel: Spalten werden nach den Überschriften automatisch
zugeordnet, die Vorschau zeigt Zeile für Zeile, was übernommen würde und was
daran nicht stimmt. Geschrieben wird erst nach Bestätigung.
- Gerätehistorie und Fahrzeughistorie, gerendert aus dem Audit-Log.
- Qualifikationen mit Ablaufwarnung, gerechnet mit der Vorwarnzeit des
jeweiligen Typs.
- Fahrzeugdetail zeigt die Beladung nach Geräteräumen statt alphabetisch.
**Daten und Tests**
- Der Seed legt jetzt zusätzlich 3 Standorte, 20 Geräteräume, 14 Gerätetypen
mit echten Prüfvorgaben (DGUV, DIN, vfdb), 49 Geräte und 107 Qualifikationen
an. Fristen sind bewusst gestreut, damit die Ampeln etwas zeigen.
- 104 Vitest-Tests (vorher 32), davon 40 zum Import und 32 zu QR und Fristen.
- 44 Playwright-Tests (vorher 10) über Desktop und Tablet.
### Geändert
- **Fristenrechnung auf UTC-Kalenderarithmetik umgestellt.** Vorher wurde über
`startOfDay` aus date-fns gerechnet, das die Zeitzone des Servers verwendet.
Ein Prüfdatum kommt aus der Datenbank als UTC-Mitternacht und wurde dabei auf
den Vortag zurückgeschoben — Fristen wären einen Tag zu früh fällig geworden.
- `CardTitle` rendert eine echte Überschrift statt eines `div`. Wer mit einer
Sprachausgabe arbeitet, springt von Überschrift zu Überschrift; Karten
tauchten in dieser Navigation vorher gar nicht auf.
- Die Entwicklungsanzeige von Next.js ist abgeschaltet, damit sie nicht in den
Bildern der Anleitung mitläuft.
### Sicherheit
- Der Etikettendruck wird protokolliert (`EXPORTIERT`) — damit ist
nachvollziehbar, wann welche Aufkleber erzeugt wurden.
- Der Import übernimmt keine Werte, die der Browser geschickt hat: die Datei
wird beim Übernehmen erneut serverseitig eingelesen und geprüft. Sonst ließe
sich die Vorschau umgehen.
## [0.1.0] — 2026-09-03 ## [0.1.0] — 2026-09-03
+38
View File
@@ -0,0 +1,38 @@
Phase 9: Kassenverwaltung.
Ergänzt am 03.09.2026. Die Rolle KASSIER ist seit Phase 0 angelegt, ein Modul
dazu fehlte in der ursprünglichen Planung.
Modelle: Kassenkonto (Bezeichnung, Art: bar | bank | ruecklage, IBAN,
Anfangsbestand, aktiv), Buchungskategorie (Name, Art: einnahme | ausgabe,
Oberkategorie, Konto im Kontenrahmen), Buchung (Konto, Kategorie, Datum,
Betrag, Verwendungszweck, Belegnummer, Gegenkonto, Person ODER
Lieferant, Beleg als Datei, festgeschrieben), Beitragssatz (Bezeichnung,
Betrag je Jahr, gilt für Abteilung), Beitrag (Person, Jahr, Satz, Betrag,
Status: offen | bezahlt | erlassen | gemahnt, bezahlt_am),
Rechnung (Empfänger, Positionen, Betrag, Datum, faellig_am, Status),
Kassenabschluss (Zeitraum, Kassenbestand laut Buch, gezählter Bestand,
Differenz, Prüfer, geprueft_am, Bericht als PDF).
Funktionen:
- Buchungsmaske mit Kategorievorschlag aus dem Verwendungszweck
- Beleg als Foto oder PDF anhängen, Ablage in MinIO
- Beitragslauf: erzeugt für ein Jahr alle Beiträge nach Abteilung,
Mahnstufen mit Vorlage, Zahlungseingang manuell oder per CSV-Abgleich
aus dem Bankkonto (CAMT.053 oder MT940)
- Rechnungen an Dritte (Fehlalarm, Öleinsatz, Vereinsveranstaltung) mit
Nummernkreis und PDF über Gotenberg
- Kassenabschluss: Zeitraum wählen, Bestand zählen, Differenz erklären,
Kassenprüfbericht als PDF mit Unterschriftsfeldern
- Auswertungen: Einnahmen/Ausgaben nach Kategorie und Monat,
Jahresvergleich, offene Posten, Beitragsrückstände
- Export für den Steuerberater und für die Kassenprüfung der Gemeinde
Wichtig:
- Eine festgeschriebene Buchung ist unveränderbar. Korrekturen erfolgen über
eine Gegenbuchung mit Verweis, nie durch Ändern.
- Der Kassenabschluss friert den Zeitraum ein: danach sind Buchungen mit
Datum in diesem Zeitraum nicht mehr möglich.
- Beträge als DECIMAL(12,2), niemals als Fließkommazahl.
- Der Kassier sieht keine Geräte- oder Atemschutzdaten, der Gerätewart keine
Buchungen. Die Trennung ist in lib/authz.ts bereits angelegt.
+2 -1
View File
@@ -24,7 +24,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Bereich | Stand | | Bereich | Stand |
|---|---| |---|---|
| Phase 0 — Fundament | fertig | | Phase 0 — Fundament | fertig |
| Phase 1 — Stammdaten | offen | | Phase 1 — Stammdaten | fertig |
| Phase 2 — Wartung und Prüfung | offen | | Phase 2 — Wartung und Prüfung | offen |
| Phase 3 — Einsätze und Übungen | offen | | Phase 3 — Einsätze und Übungen | offen |
| Phase 4 — Kiosk-PWA | offen | | Phase 4 — Kiosk-PWA | offen |
@@ -32,6 +32,7 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 6 — Kartenmodule | offen | | Phase 6 — Kartenmodule | offen |
| Phase 7 — Benachrichtigungen und Termine | offen | | Phase 7 — Benachrichtigungen und Termine | offen |
| Phase 8 — Lokale KI | offen | | Phase 8 — Lokale KI | offen |
| Phase 9 — Kassenverwaltung | offen |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md). [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
+239 -10
View File
@@ -1,11 +1,10 @@
# Anleitung # Anleitung
Bedienung von FLORIAN, Stand Phase 0 (Version 0.1.0). Die Bilder zeigen die Bedienung von FLORIAN, Stand Phase 1 (Version 0.2.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.
> Was hier noch fehlt — Mitglieder, Geräte, Prüfungen — kommt in den > Prüfungen, Einsätze, Atemschutz und die Kiosk-Oberfläche kommen in den
> folgenden Phasen dazu. Der Stand ist bewusst schmal: Phase 0 baut das > folgenden Phasen dazu. Siehe [ROADMAP.md](ROADMAP.md).
> Fundament, nicht die Fachmodule. Siehe [ROADMAP.md](ROADMAP.md).
--- ---
@@ -94,7 +93,231 @@ nicht geblendet werden.
--- ---
## 4. Rollen und was sie dürfen ## 4. Geräte
![Geräteliste](screenshots/03-geraeteliste-light.png)
Die Geräteliste ist die Ansicht, die am häufigsten offen ist. Von links nach
rechts steht dort, was man beim Suchen braucht: Inventarnummer, was es ist, von
wem, wo es liegt, in welchem Zustand und wann es wieder geprüft werden muss.
### Suchen und filtern
**Das Suchfeld** durchsucht Inventarnummer, Bezeichnung, Hersteller, Modell und
Seriennummer gleichzeitig. Es genügt ein Teil — „press" findet alle
Pressluftatmer. Gesucht wird erst, wenn man kurz aufhört zu tippen; das
verhindert eine Abfrage bei jedem Anschlag.
**Alle Zustände** schränkt auf einen Status ein. Praktisch, wenn man wissen
will, was gerade fällig oder gesperrt ist.
**Spalten** blendet Spalten ein und aus. Zwölf stehen zur Auswahl, sechs sind
voreingestellt. Die Auswahl bleibt im Browser gespeichert — der Gerätewart
schaut auf Prüftermine, der Kassier auf Anschaffungspreise, und beide finden
ihre Einstellung beim nächsten Mal wieder.
**Sortiert** wird durch einen Klick auf die Spaltenüberschrift, ein zweiter
Klick dreht die Richtung um.
### Farben und Fristen
| Farbe | Bedeutung |
|---|---|
| grün | einsatzbereit |
| gelb | Prüfung fällig, oder eine Frist läuft in den nächsten 30 Tagen ab |
| rot | gesperrt, ausgesondert oder überfällig |
| grau | in Reparatur |
Hinter jedem Datum steht, wie viele Tage noch bleiben — „in 12 Tagen" sagt mehr
als ein Datum, das man erst im Kopf ausrechnen muss. Was vorbei ist, steht als
„seit 62 Tagen" da.
Die Farbe allein trägt nie die Information: daneben steht immer Klartext.
### Mehrere Geräte auf einmal
Über die Kästchen links lassen sich Geräte auswählen. Sobald mindestens eines
ausgewählt ist, erscheint eine Leiste mit zwei Möglichkeiten:
- **Etiketten drucken** erzeugt den Etikettenbogen als PDF.
- **Status setzen auf …** ändert den Zustand aller ausgewählten Geräte. Der
häufigste Handgriff überhaupt: Nach einer Sammelprüfung stehen zwanzig Geräte
auf einmal wieder auf einsatzbereit.
Jede Änderung wird einzeln protokolliert — am jeweiligen Gerät, nicht als
Sammelvorgang, den später niemand mehr zuordnen kann.
---
## 5. Ein einzelnes Gerät
![Gerätedetail](screenshots/04-geraet-light.png)
### Woher das Prüfintervall kommt
Steht am Gerät kein eigenes Intervall, gilt die Vorgabe des Gerätetyps. Das
Bild zeigt es: „alle 12 Monate (aus dem Gerätetyp)". So muss man bei vierzig
Pressluftatmern nicht vierzigmal dasselbe eintragen. Schreibt der Hersteller
für eine bestimmte Charge etwas anderes vor, wird der Wert am Gerät gesetzt und
gewinnt dann.
Dasselbe gilt für die Lebensdauer. Ob sie ab Herstellung oder ab
Indienststellung zählt, hängt von der Vorschrift ab: Seile und Druckbehälter
altern ab Herstellung, Schutzkleidung meist ab Indienststellung.
### QR-Code
Rechts steht der QR-Code des Geräts. **Etikett drucken** erzeugt daraus einen
Aufkleber für den Geräteraum.
Der Code zeigt auf `.../g/<Inventarnummer>`. Wer ihn scannt und angemeldet ist,
landet direkt auf dieser Seite. Wer nicht angemeldet ist, landet zuerst auf der
Anmeldung und danach hier — **der Aufkleber ist keine Berechtigung.** Wer ihn
fotografiert, bekommt dadurch keinen Zugang.
Bewusst steht die Inventarnummer im Code und nicht die interne Kennung: Eine
UUID wäre 36 Zeichen lang, das Muster entsprechend fein, und auf einem
38 × 21 mm großen Aufkleber im staubigen Geräteraum wird das unzuverlässig.
### Verlauf
Unten steht, was mit dem Gerät passiert ist — direkt aus dem Audit-Log. Es gibt
bewusst keine zweite Historie: zwei Quellen könnten auseinanderlaufen.
---
## 6. Etiketten drucken
Der Bogen ist auf das gängige Format mit **65 Etiketten je DIN-A4-Seite**
(38,1 × 21,2 mm, 5 Spalten × 13 Zeilen) ausgelegt — den gibt es in jedem
Schreibwarenladen und er passt in jeden Laserdrucker.
Auf jedem Etikett stehen drei Dinge: der QR-Code, die Inventarnummer zum
Ablesen ohne Handy, und die Bezeichnung. Bei verlasteten Geräten kommt der
Funkrufname des Fahrzeugs dazu.
Der Druck kann an einer beliebigen Position des Bogens beginnen. Wer drei
Etiketten braucht, muss so keinen ganzen Bogen opfern.
> Erzeugt wird das PDF von Gotenberg, einem eigenen Container im Stack. Kommt
> die Meldung „Der Etikettenbogen ließ sich nicht erzeugen", läuft er nicht:
> `docker compose ps` zeigt es.
---
## 7. Gerät scannen
Über **Scannen** in der Geräteliste öffnet sich der QR-Scanner. Der Browser
fragt beim ersten Mal nach der Kamerafreigabe. Das Bild verlässt das Gerät
nicht — ausgewertet wird direkt im Browser.
Zwei Dinge sind bewusst so gebaut:
- **Es geht immer auch ohne Kamera.** Darunter steht ein Eingabefeld für die
Inventarnummer. Wenn die Freigabe verweigert wurde oder das Etikett zerkratzt
ist, tippt man sie ein.
- **Fremde Codes brechen nichts ab.** Der Barcode des Herstellers klebt oft
direkt daneben. Wird er erkannt, kommt ein Hinweis statt einer Fehlermeldung.
---
## 8. Fahrzeuge
![Fahrzeugdetail](screenshots/06-fahrzeug-light.png)
Die Übersicht zeigt die Fahrzeuge als Kacheln — eine Wehr hat drei bis zehn,
nicht dreihundert. Auf jeder Kachel stehen der nächste TÜV-Termin und die Zahl
der verlasteten Geräte.
Auf der Detailseite steht die **Beladung nach Geräteräumen**, in der Reihenfolge
der Beladeordnung: G1 bis G6, Heck, Dach, Mannschaftsraum. Genau so sucht der
Gerätewart — nicht „wo ist die Motorsäge", sondern „was ist in G3".
Beim Anlegen eines neuen Fahrzeugs werden diese Räume auf Wunsch gleich
mitangelegt. Ein Mannschaftstransportwagen bekommt weniger Fächer als ein
Löschfahrzeug.
Ein Gerät liegt **entweder** an einem Standort **oder** auf einem Fahrzeug.
Beides gleichzeitig lässt die Datenbank nicht zu — das ist keine Formalität,
sondern verhindert, dass ein Gerät zweimal gezählt wird.
Was verlastet ist, aber keinem Raum zugeordnet wurde, erscheint unter „Ohne
Raumangabe" statt stillschweigend zu verschwinden.
---
## 9. Mitglieder und Qualifikationen
![Qualifikationen](screenshots/08-qualifikationen-light.png)
Die Mitgliederliste ist nach Abteilung gruppiert — Aktive, Jugendfeuerwehr,
Passive. Eine durchlaufende alphabetische Liste vermischt Gruppen, die in der
Wehr nichts miteinander zu tun haben.
Auf der Seite **Qualifikationen** steht oben, was abgelaufen ist oder demnächst
abläuft. Das ist der Grund, warum jemand diese Seite öffnet.
**Gewarnt wird mit der Vorwarnzeit des jeweiligen Typs**, nicht mit einer
pauschalen Frist. Eine G26.3-Untersuchung braucht 120 Tage Vorlauf, weil erst
ein Termin beim Betriebsarzt gefunden werden muss; eine jährliche Unterweisung
kommt mit deutlich weniger aus. Einsatzrelevante Qualifikationen sind
gekennzeichnet.
Das Ablaufdatum wird beim Speichern aus der Gültigkeitsdauer des Typs
gerechnet — lässt sich aber überschreiben. Auf einer Urkunde steht manchmal
etwas anderes, als die Regel hergäbe.
---
## 10. Daten importieren
![Import-Assistent](screenshots/09-import-light.png)
Der Import geht in drei Schritten, und der mittlere ist der eigentliche Punkt.
### Schritt 1: Datei wählen
CSV oder Excel (`.xlsx`). Die erste Zeile muss die Spaltenüberschriften
enthalten. Höchstens 10 MB und 5.000 Zeilen.
Erkannt werden die Formate, die in der Praxis herauskommen:
| Fall | Wird verstanden |
|---|---|
| Trennzeichen | Semikolon, Komma, Tabulator, senkrechter Strich |
| Zeichensatz | UTF-8 (auch mit BOM) und Windows-1252 |
| Datum | 02.09.2026, 2.9.26, 2026-09-02, und die Seriennummer aus Excel |
| Zahlen | 1.234,56 und 1,234.56, mit und ohne Eurozeichen |
| Ja/Nein | ja, j, x, 1, wahr — und die Gegenstücke |
### Schritt 2: Zuordnung prüfen
FLORIAN rät die Zuordnung anhand der Überschriften. „Inv.-Nr." landet bei
Inventarnummer, „Fabrikat" bei Hersteller. Jede Zuordnung lässt sich ändern,
und die Vorschau passt sich sofort an.
**Die Vorschau ist der Grund, warum es diesen Zwischenschritt gibt.** Sie zeigt
Zeile für Zeile, was übernommen würde, und markiert rot, was nicht stimmt:
fehlende Pflichtfelder, unlesbare Daten, Inventarnummern, die in der Datei
doppelt vorkommen. Eine falsch zugeordnete Spalte verteilt sonst Seriennummern
auf Inventarnummern, und das merkt man erst Wochen später am Regal.
### Schritt 3: Übernehmen
Übernommen wird nur, was fehlerfrei ist. Zeilen mit Fehlern bleiben in der
Datei und lassen sich nach der Korrektur erneut einlesen.
**Vorhandene Datensätze aktualisieren** entscheidet, was mit Datensätzen
passiert, die es schon gibt: überspringen (Vorgabe) oder überschreiben.
Erkannt werden sie über die Inventarnummer, bei Mitgliedern über die
Mitgliedsnummer oder die E-Mail-Adresse.
Zum Schluss steht da, wie viele Datensätze angelegt, aktualisiert und
übersprungen wurden. Der ganze Vorgang steht auch im Protokoll.
---
## 11. 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
@@ -123,7 +346,7 @@ Das Terminal selbst ist nur die Tür.
--- ---
## 5. Anmeldung über authentik einrichten ## 12. 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.
@@ -176,7 +399,7 @@ ganze Wehr aus.
--- ---
## 6. Terminal-Anmeldung im Gerätehaus ## 13. Terminal-Anmeldung im Gerätehaus
Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits. Die Oberfläche dazu kommt in Phase 4; der Anmeldeweg steht bereits.
@@ -198,7 +421,7 @@ Huber), PIN `471203`.
--- ---
## 7. Was protokolliert wird ## 14. 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.
@@ -218,11 +441,12 @@ einzige Weg, auf dem etwas aus dem Log verschwindet.
dem Schreiben herausgenommen. dem Schreiben herausgenommen.
Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und Einsehbar ist der Log mit dem Recht `system.auditlog`, das Kommandant und
Administrator haben. Die Ansicht dazu kommt in Phase 1. Administrator haben. Am Gerät und am Fahrzeug ist der Verlauf bereits auf der
jeweiligen Detailseite sichtbar.
--- ---
## 8. Wenn etwas nicht geht ## 15. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft | | Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---| |---|---|---|
@@ -231,6 +455,11 @@ Administrator haben. Die Ansicht dazu kommt in Phase 1.
| Listen sind leer, obwohl Daten da sind | Kein Mandantenkontext gesetzt | Das ist der Schutzmechanismus. Der Zugriff muss über `fuerOrganisation()` laufen. | | Listen sind leer, obwohl Daten da sind | Kein Mandantenkontext gesetzt | Das ist der Schutzmechanismus. Der Zugriff muss über `fuerOrganisation()` laufen. |
| Dateien lassen sich nicht hochladen | MinIO nicht erreichbar oder Bucket fehlt | `http://localhost:9011` öffnen; `docker compose logs minio-init` | | Dateien lassen sich nicht hochladen | MinIO nicht erreichbar oder Bucket fehlt | `http://localhost:9011` öffnen; `docker compose logs minio-init` |
| Der Knopf für authentik fehlt | Issuer, Client-ID oder Secret nicht gesetzt | Alle drei sind nötig, sonst wird der Weg ausgeblendet | | Der Knopf für authentik fehlt | Issuer, Client-ID oder Secret nicht gesetzt | Alle drei sind nötig, sonst wird der Weg ausgeblendet |
| Etikettenbogen lässt sich nicht erzeugen | Gotenberg läuft nicht | `docker compose ps`, dann `docker compose up -d gotenberg` |
| Kamera bleibt schwarz | Freigabe abgelehnt, oder die Seite läuft nicht über HTTPS | Der Browser fragt nur bei HTTPS oder localhost. Sonst die Inventarnummer eintippen |
| Import meldet fehlende Pflichtfelder | Keine Spalte ist Inventarnummer oder Bezeichnung zugeordnet | In Schritt 2 die Zuordnung von Hand setzen |
| Umlaute im Import verstümmelt | Die Datei ist weder UTF-8 noch Windows-1252 | In Excel als „CSV UTF-8" speichern |
| Gerät lässt sich nicht speichern | Standort **und** Fahrzeug gesetzt, oder keines von beidem | Genau eines von beiden angeben |
| `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben | | `docker compose up` startet Traefik mit | Override-Datei wurde nicht mitgeladen | In Produktion `-f docker-compose.yml` angeben |
Zustand aller Randsysteme auf einen Blick: Zustand aller Randsysteme auf einen Blick:
+37 -11
View File
@@ -31,7 +31,7 @@ Faktoren, die in die Schätzung eingehen:
| Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich | | Phase | Inhalt | Vergleichsaufwand | Sitzungszeit geschätzt | Sitzungszeit tatsächlich |
|---|---|---:|---:|---:| |---|---|---:|---:|---:|
| 0 | Fundament | 12–16 PT | — | **1:00 h** | | 0 | Fundament | 12–16 PT | — | **1:00 h** |
| 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | offen | | 1 | Stammdaten | 10–14 PT | 2,5–3,5 h | **1:15 h** |
| 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen | | 2 | Wartung und Prüfung | 18–24 PT | 4,5–6 h | offen |
| 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen | | 3 | Einsätze und Übungen | 12–16 PT | 3–4 h | offen |
| 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 | offen |
@@ -39,9 +39,10 @@ Faktoren, die in die Schätzung eingehen:
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen | | 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | offen |
| 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 | offen |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen | | 8 | Lokale KI | 9–12 PT | 2,5–3 h | offen |
| | **Summe** | **115–153 PT** | **26–34 h** | | | 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | |
115–153 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer 123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
je nach Umfang 500 bis 2.500 Euro im Jahr, dauerhaft, mit den Daten beim je nach Umfang 500 bis 2.500 Euro im Jahr, dauerhaft, mit den Daten beim
Anbieter. Anbieter.
@@ -71,7 +72,7 @@ das nachträglich einbaut, zahlt ein Vielfaches.
## Phase 1 — Stammdaten ## Phase 1 — Stammdaten
**Vergleichsaufwand: 10–14 PT** **Vergleichsaufwand: 10–14 PT** · **Tatsächlich: 1:15 h Sitzungszeit**
| Baustein | PT | | Baustein | PT |
|---|---:| |---|---:|
@@ -88,6 +89,17 @@ Der Import ist der Posten, der erfahrungsgemäß aus dem Ruder läuft. Jede Wehr
bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist bringt eine andere Excel-Datei mit, und die Vorschau vor dem Übernehmen ist
kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut. kein Komfort, sondern die Bedingung dafür, dass die Wehr dem Ergebnis traut.
**Rückblick.** Die Schätzung hat gut getroffen — mit einer Verschiebung: Der
Import war teurer als angesetzt (deutsche Datumsformate, Semikolon-CSV,
Windows-1252, Excel-Seriennummern, Dubletten in der Datei), die Tabellen
dagegen billiger, weil Sortierung und Blättern serverseitig laufen und keine
Tabellenbibliothek nötig war. Nicht eingeplant war die Zeitzonen-Falle in der
Fristenrechnung; sie hätte in Phase 2 deutlich mehr gekostet, wenn sie erst
dort aufgefallen wäre.
Offen geblieben ist ein Formular zum einzelnen Anlegen von Geräten
(geschätzt 0,5 PT), siehe [DEVLOG.md](DEVLOG.md).
## Phase 2 — Wartung und Prüfung ## Phase 2 — Wartung und Prüfung
**Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase **Vergleichsaufwand: 18–24 PT** — die aufwendigste Phase
@@ -221,17 +233,31 @@ Kennzeichnungspflicht — jede KI-Ausgabe ist als solche erkennbar und muss
bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt — bestätigt werden, bevor sie in ein Dokument mit Nachweischarakter gelangt —
kostet wenig Zeit, ist aber nicht verhandelbar. kostet wenig Zeit, ist aber nicht verhandelbar.
## Phase 9 — Kassenverwaltung
**Vergleichsaufwand: 8–11 PT** — am 03.09.2026 ergänzt
| Baustein | PT |
|---|---:|
| Modelle: Konto, Kategorie, Buchung, Beitragssatz, Beitrag, Rechnung, Abschluss | 2,0 |
| Buchungsmaske mit Kategorievorschlag, Beleg als Datei | 1,5 |
| Beitragslauf, Mahnstufen, Zahlungsabgleich aus CAMT.053 oder MT940 | 2,5 |
| Rechnungen mit Nummernkreis und PDF | 1,0 |
| Kassenabschluss mit Zeitraumsperre und Prüfbericht | 1,5 |
| Auswertungen und Exporte | 1,5 |
Der Zahlungsabgleich ist der unsichere Posten: CAMT.053 ist gut spezifiziert,
MT940 dagegen je nach Bank unterschiedlich ausgelegt. Falls die Hausbank nur
MT940 liefert, kommt ein halber bis ganzer Tag für die Eigenheiten dazu.
Die Unveränderlichkeit festgeschriebener Buchungen kostet hier fast nichts —
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
übernehmen.
--- ---
## Was hier nicht drin ist ## Was hier nicht drin ist
**Kassenmodul.** Die Rolle `KASSIER` ist in Phase 0 gefordert und angelegt,
die Rechte `kasse.lesen`, `kasse.bearbeiten` und `kasse.abschluss` stehen
bereit. Ein Kassenmodul kommt in keiner der Phasen 0 bis 8 vor. Geschätzter
Vergleichsaufwand, falls gewünscht: **8–11 PT** für Buchungen, Beiträge,
Rechnungen, Kassenabschluss und Jahresauswertung. Siehe
[ROADMAP.md](ROADMAP.md).
**Betrieb und Einführung.** Datenübernahme aus der Altsoftware, Schulung der **Betrieb und Einführung.** Datenübernahme aus der Altsoftware, Schulung der
Wehr, Sicherungskonzept, Monitoring: nach Erfahrung noch einmal 15 bis 25 Wehr, Sicherungskonzept, Monitoring: nach Erfahrung noch einmal 15 bis 25
Prozent des Entwicklungsaufwands. Prozent des Entwicklungsaufwands.
+87 -1
View File
@@ -11,7 +11,93 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase | Sitzungszeit | Sitzungen | abgeschlossen | | Phase | Sitzungszeit | Sitzungen | abgeschlossen |
|---|---:|---:|---| |---|---:|---:|---|
| Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 | | Phase 0 — Fundament | 1:00 h | 1 | 03.09.2026 |
| **Summe** | **1:00 h** | **1** | | | Phase 1 — Stammdaten | 1:15 h | 1 | 03.09.2026 |
| **Summe** | **2:15 h** | **2** | |
---
## 2026-09-03 · Phase 1 — Stammdaten
**07:10 – 08:25 · 1:15 h · Version 0.2.0**
### Was entstanden ist
41 neue Quelldateien, der Umfang wächst von rund 6.100 auf 15.600 Zeilen.
| Bereich | Inhalt |
|---|---|
| Datenmodell | 5 neue Tabellen (Standort, Geraeteraum, Geraetetyp, Qualifikationstyp, Qualifikation), Fahrzeug und Geraet deutlich erweitert, 2 Migrationen |
| Fachlogik | `fristen.ts` (Prüftermine, Lebensdauer, Statusableitung), `qr.ts` und `qr-parser.ts`, `etiketten.ts`, `pdf.ts`, `import.ts` und `import-felder.ts` |
| Server | 8 tRPC-Router mit Liste, Detail, Anlegen, Ändern, Massenbearbeitung und Verlauf |
| Oberfläche | Geräteliste mit Suche und Spaltenauswahl, Gerätedetail mit QR, Fahrzeugliste und -detail mit Beladung, Mitgliederliste, Mitgliedsdetail, Qualifikationen, Import-Assistent, QR-Scanner |
| Daten | Seed erweitert: 3 Standorte, 20 Geräteräume, 14 Gerätetypen, 49 Geräte, 14 Qualifikationstypen, 107 Qualifikationen |
| Tests | 104 Vitest-Tests (vorher 32), 44 Playwright-Tests (vorher 10) |
### Entscheidungen
**Der Gerätetyp ist Vorgabe, das Gerät darf abweichen.** Vierzig Pressluftatmer
haben dasselbe Prüfintervall — das gehört einmal an den Typ und nicht
vierzigmal an das Gerät. Wo der Hersteller für eine Charge etwas anderes
vorschreibt, gewinnt der Wert am Gerät. Umgesetzt in `effektiverWert()`, das
bewusst zwischen `null` und `0` unterscheidet: ein Intervall von 0 ist eine
Angabe, kein fehlender Wert.
**Ein Gerät liegt entweder an einem Standort oder auf einem Fahrzeug.** Beides
gleichzeitig wäre eine Falschangabe, keines von beidem auch. Das steht als
Prüfbedingung in der Datenbank, nicht nur im Formular — und ein Trigger prüft
zusätzlich, dass ein angegebener Geräteraum wirklich zu dem angegebenen
Fahrzeug gehört. Das ließe sich als CHECK nicht ausdrücken.
**Der QR-Code enthält die Inventarnummer, nicht die UUID.** Eine UUID wäre
36 Zeichen lang; der Code bekäme ein feines Muster, und auf einem 38 × 21 mm
großen Etikett im staubigen Geräteraum wird das unzuverlässig. Die
Inventarnummer steht ohnehin lesbar daneben, und wer den Code mit einem
beliebigen Scanner liest, sieht sofort, um welches Gerät es geht.
**Der Import liest die Datei zweimal.** Die Vorschau zeigt, was passieren
würde; beim Übernehmen wird die Datei erneut hochgeladen und serverseitig neu
eingelesen. Es wandert kein einziger Wert aus dem Browser in die Datenbank.
Der Preis ist eine zweite Übertragung — bei den Größen, um die es geht, kein
Thema.
**Der Etikettenbogen kann an beliebiger Position beginnen.** Wer drei Etiketten
braucht, soll nicht einen ganzen Bogen opfern.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Fristen um einen Tag verschoben | `startOfDay` aus date-fns rechnet in der Zeitzone des Servers; ein DATE aus der Datenbank kommt als UTC-Mitternacht und landete dadurch auf dem Vortag | eigene Kalenderarithmetik auf UTC-Bestandteilen, „heute“ über Europe/Berlin bestimmt |
| `ctx.sitzung` blieb nullbar | `t.middleware()` erzeugt eine Middleware auf dem Wurzelkontext; die Verengung aus der vorigen Middleware ging verloren | Middleware direkt als Funktion an `.use()` übergeben |
| Import-Tests brachen beim Laden ab | `import "server-only"` wirft beim blossen Import, und Vitest ist weder Server noch Browser | im Testlauf durch ein leeres Modul ersetzt (`tests/stubs/server-only.ts`) |
| 15 E2E-Tests im Zeitlimit | `next dev` übersetzt jede Route erst beim ersten Aufruf; zu viele parallele Erstaufrufe | Worker auf 4 begrenzt, Zeitlimit angehoben |
| 5 weitere E2E-Tests rot | `CardTitle` war ein `div` und damit keine Überschrift | in der Komponente auf ein echtes `h3` umgestellt — behebt zugleich einen Zugänglichkeitsmangel |
| Erste Bilder zeigten Ladebalken | „networkidle“ greift zu früh: die Listen holen ihre Daten erst nach, wenn React im Browser gelaufen ist | Screenshot-Skript wartet, bis keine Ladeanzeige mehr im Baum ist |
| Demodaten inkonsistent | die 40 Geräte aus Phase 0 hatten keinen Gerätetyp, und der Seed ist idempotent | Geräte-Zeilen der Entwicklungsdatenbank gezielt geleert und neu eingespielt |
### Abweichung von der Vorgabe
**`prisma migrate reset` wurde nicht ausgeführt.** Der naheliegende Weg, die
Demodaten sauber neu aufzubauen, verlangt eine ausdrückliche Freigabe — Prisma
verweigert den Befehl gegenüber einem KI-Agenten ohne Zustimmung. Statt zu
fragen wurde der schmalere Weg gewählt: nur die Tabelle `geraet` der
Entwicklungsdatenbank geleert und neu eingespielt.
**Kein eigenes Formular zum Anlegen von Geräten.** Die Router können anlegen,
ändern, umlagern und aussondern; die Oberfläche dazu deckt Liste, Detail,
Massenbearbeitung und Import ab. Ein einzelnes Anlegeformular fehlt noch — in
der Praxis kommen Bestände als Tabelle herein, und einzelne Neuzugänge lassen
sich bis dahin über den Import erfassen. Nachzuholen in Phase 2, wo ohnehin
Formulare für Prüfungen entstehen.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run test` — 104 Tests grün, davon 8 gegen eine echte Datenbank
- `npm run test:e2e` — 44 Tests grün (Desktop und Tablet)
- `npm run build` — 17 Routen, 103 kB gemeinsames JavaScript
- `npm run screenshots` — 18 Bilder, hell und dunkel
- Keine Konsolenfehler und keine Hydratationswarnungen im Browser
--- ---
+34 -20
View File
@@ -10,7 +10,7 @@ Versionszuordnung: [VERSIONIERUNG.md](VERSIONIERUNG.md).
``` ```
Phase 0 ████████████████████ fertig 0.1.0 Phase 0 ████████████████████ fertig 0.1.0
Phase 1 ░░░░░░░░░░░░░░░░░░░░ offen 0.2.0 Phase 1 ████████████████████ fertig 0.2.0
Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0 Phase 2 ░░░░░░░░░░░░░░░░░░░░ offen 0.3.0
Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0 Phase 3 ░░░░░░░░░░░░░░░░░░░░ offen 0.4.0
Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0 Phase 4 ░░░░░░░░░░░░░░░░░░░░ offen 0.5.0
@@ -18,6 +18,7 @@ Phase 5 ░░░░░░░░░░░░░░░░░░░░ offen
Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0 Phase 6 ░░░░░░░░░░░░░░░░░░░░ offen 0.7.0
Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0 Phase 7 ░░░░░░░░░░░░░░░░░░░░ offen 0.8.0
Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0 Phase 8 ░░░░░░░░░░░░░░░░░░░░ offen 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ offen 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0 Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
``` ```
@@ -38,7 +39,7 @@ Alles, worauf der Rest aufsetzt.
- Basis-Layout mit Seitenleiste und dunkler Darstellung - Basis-Layout mit Seitenleiste und dunkler Darstellung
- Seed mit einer vollständigen Beispielwehr - Seed mit einer vollständigen Beispielwehr
## Phase 1 — Stammdaten · als Nächstes ## Phase 1 — Stammdaten · fertig
Die Wehr trägt ein, was sie hat. Die Wehr trägt ein, was sie hat.
@@ -56,7 +57,7 @@ Die Wehr trägt ein, was sie hat.
**Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per **Danach kann die Wehr:** ihren Bestand vollständig führen und jedes Gerät per
QR-Code am Regal wiederfinden. QR-Code am Regal wiederfinden.
## Phase 2 — Wartung und Prüfung ## Phase 2 — Wartung und Prüfung · als Nächstes
Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner. Das wichtigste Modul. Ab hier ersetzt FLORIAN den Ordner.
@@ -159,6 +160,28 @@ Alles gegen Ollama, kein externer Anbieter, komplett abschaltbar.
Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein Jede KI-Ausgabe ist gekennzeichnet und muss bestätigt werden, bevor sie in ein
Dokument mit Nachweischarakter gelangt. Dokument mit Nachweischarakter gelangt.
## Phase 9 — Kassenverwaltung
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
- Kassenkonten (Bar, Bank, Rücklage), Buchungskategorien, Buchungen mit Beleg
- Beitragslauf je Jahr nach Abteilung, Mahnstufen, Zahlungsabgleich aus
CAMT.053 oder MT940
- Rechnungen an Dritte mit Nummernkreis und PDF
- Kassenabschluss mit gezähltem Bestand, Differenz und Kassenprüfbericht
- Auswertungen nach Kategorie und Monat, offene Posten, Beitragsrückstände
- Export für Steuerberater und Kassenprüfung der Gemeinde
Zwei Festlegungen, die den Rest bestimmen: eine festgeschriebene Buchung ist
unveränderbar — Korrekturen laufen über eine Gegenbuchung mit Verweis. Und der
Kassenabschluss friert seinen Zeitraum ein, danach nimmt die Buchungsmaske
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
Fließkommazahl.
**Danach kann die Wehr:** ihre Kasse führen und den Kassenprüfbericht auf
Knopfdruck erzeugen.
## 1.0.0 — Freigabe ## 1.0.0 — Freigabe
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
@@ -173,26 +196,17 @@ Echtbetrieb ein. Voraussetzungen:
--- ---
## Offene Punkte ## Getroffene Festlegungen
### Kassenmodul fehlt in der Phasenplanung Am 03.09.2026 entschieden:
Phase 0 verlangt die Rolle `KASSIER`, und sie ist angelegt — mit den Rechten | Punkt | Entscheidung |
`kasse.lesen`, `kasse.bearbeiten` und `kasse.abschluss`. Ein Kassenmodul kommt |---|---|
in keiner der Phasen 0 bis 8 vor. Die Rolle hätte also dauerhaft Rechte ohne | Kassenmodul | Wird als **Phase 9** ergänzt. Spezifikation in `Phase-9.md`, Aufwand 8–11 PT. |
zugehörige Oberfläche. | `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
Zwei Wege: ## Weitere Kandidaten für später
1. **Phase 9 ergänzen** — Buchungen, Beiträge, Rechnungen, Kassenabschluss,
Jahresauswertung. Geschätzt 8–11 PT.
2. **Rolle belassen, Modul streichen** — der Kassier nutzt weiterhin seine
bisherige Lösung, die Rechte bleiben als Platzhalter stehen.
Solange das nicht entschieden ist, bleiben die Rechte im Katalog, aber es gibt
keinen Navigationseintrag dafür.
### Weitere Kandidaten für später
| Idee | Nutzen | grob | | Idee | Nutzen | grob |
|---|---|---:| |---|---|---:|
+5 -1
View File
@@ -30,9 +30,13 @@ Versionsnummer eine Aussage über den Funktionsumfang.
| `0.7.0` | Phase 6 — Kartenmodule | | `0.7.0` | Phase 6 — Kartenmodule |
| `0.8.0` | Phase 7 — Benachrichtigungen und Termine | | `0.8.0` | Phase 7 — Benachrichtigungen und Termine |
| `0.9.0` | Phase 8 — Lokale KI | | `0.9.0` | Phase 8 — Lokale KI |
| `0.10.0` | Phase 9 — Kassenverwaltung |
| `1.0.0` | Freigabe für den Wirkbetrieb durch die Wehr | | `1.0.0` | Freigabe für den Wirkbetrieb durch die Wehr |
`1.0.0` ist bewusst an die **Freigabe** gebunden, nicht an Phase 8. Erst wenn `0.10.0` ist nach SemVer korrekt größer als `0.9.0` — die Stellen werden als
Zahlen verglichen, nicht als Text.
`1.0.0` ist bewusst an die **Freigabe** gebunden, nicht an die letzte Phase. Erst wenn
eine Wehr die Software im Echtbetrieb einsetzt, darf die analoge Überwachung eine Wehr die Software im Echtbetrieb einsetzt, darf die analoge Überwachung
zurücktreten — und erst dann ist eine Zusage über Stabilität ehrlich. zurücktreten — und erst dann ist eine Zusage über Stabilität ehrlich.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 727 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

+3
View File
@@ -8,6 +8,9 @@ const nextConfig: NextConfig = {
images: { remotePatterns: [] }, images: { remotePatterns: [] },
serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"], serverExternalPackages: ["@prisma/client", "bcryptjs", "minio", "bullmq"],
eslint: { ignoreDuringBuilds: true }, eslint: { ignoreDuringBuilds: true },
// Die Entwicklungsanzeige unten links wuerde in den Dokumentationsbildern
// mitlaufen. Sie betrifft ohnehin nur "next dev".
devIndicators: false,
async headers() { async headers() {
return [ return [
{ {
+1578 -13
View File
File diff suppressed because it is too large Load Diff
+15 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "florian", "name": "florian",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform", "description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": { "engines": {
@@ -28,26 +28,38 @@
"dependencies": { "dependencies": {
"@prisma/client": "^6.16.2", "@prisma/client": "^6.16.2",
"@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.11",
"@radix-ui/react-dialog": "^1.1.23",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-popover": "^1.1.23",
"@radix-ui/react-select": "^2.3.7",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.21",
"@radix-ui/react-tooltip": "^1.2.16",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-table": "^9.2.4",
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"@zxing/browser": "^0.2.1",
"@zxing/library": "^0.23.0",
"bcryptjs": "^3.0.2", "bcryptjs": "^3.0.2",
"bullmq": "^5.61.0", "bullmq": "^5.61.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0", "date-fns-tz": "^3.2.0",
"exceljs": "^4.4.0",
"ioredis": "^5.8.1", "ioredis": "^5.8.1",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"minio": "^8.0.6", "minio": "^8.0.6",
"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",
"papaparse": "^5.7.0",
"qrcode": "^1.5.4",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"server-only": "^0.0.1", "server-only": "^0.0.1",
@@ -61,6 +73,8 @@
"@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/papaparse": "^5.5.2",
"@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",
"@vitejs/plugin-react": "^5.0.3", "@vitejs/plugin-react": "^5.0.3",
+11 -4
View File
@@ -14,10 +14,17 @@ export default defineConfig({
fullyParallel: true, fullyParallel: true,
forbidOnly: Boolean(process.env.CI), forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined, /**
* Bewusst wenige Worker: "next dev" übersetzt jede Route erst beim ersten
* Aufruf. Zu viele gleichzeitige Erstaufrufe lassen den Server so lange
* kompilieren, dass die Navigation ins Zeitlimit läuft — und das sieht dann
* nach einem Fehler in der Anwendung aus, ist aber keiner.
*/
workers: process.env.CI ? 1 : 4,
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]], reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : [["list"]],
timeout: 30_000, /** Großzügig, aus demselben Grund. Gegen einen Produktionsbuild reicht ein Bruchteil. */
expect: { timeout: 10_000 }, timeout: 90_000,
expect: { timeout: 15_000 },
use: { use: {
baseURL: BASIS_URL, baseURL: BASIS_URL,
@@ -44,6 +51,6 @@ export default defineConfig({
command: "npm run dev", command: "npm run dev",
url: BASIS_URL, url: BASIS_URL,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
timeout: 120_000, timeout: 180_000,
}, },
}); });
@@ -0,0 +1,234 @@
-- CreateEnum
CREATE TYPE "StandortArt" AS ENUM ('GERAETEHAUS', 'LAGER', 'AUSSENSTELLE', 'WERKSTATT', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "GeraeteKategorie" AS ENUM ('ATEMSCHUTZ', 'BELEUCHTUNG', 'BRANDBEKAEMPFUNG', 'ELEKTRO', 'FUNK', 'HYGIENE', 'MEDIZIN', 'MESSTECHNIK', 'PERSOENLICHE_SCHUTZAUSRUESTUNG', 'RETTUNG', 'SCHLAUCHMATERIAL', 'TECHNISCHE_HILFELEISTUNG', 'UMWELTSCHUTZ', 'WASSERFOERDERUNG', 'SONSTIGES');
-- CreateEnum
CREATE TYPE "LebensdauerKategorie" AS ENUM ('KEINE', 'HELM', 'SCHUTZKLEIDUNG', 'HANDSCHUHE', 'STIEFEL', 'SEIL_GURT', 'PRESSLUFTATMER', 'ATEMANSCHLUSS', 'DRUCKBEHAELTER', 'SCHLAUCH', 'LEITER', 'SONSTIGE');
-- CreateEnum
CREATE TYPE "LebensdauerBezug" AS ENUM ('HERSTELLUNG', 'INDIENSTSTELLUNG');
-- CreateEnum
CREATE TYPE "QualifikationsArt" AS ENUM ('LEHRGANG', 'BEFAEHIGUNG', 'UNTERSUCHUNG', 'FUEHRERSCHEIN', 'UNTERWEISUNG', 'SONSTIGES');
-- AlterTable
ALTER TABLE "fahrzeug" ADD COLUMN "aufbauhersteller" VARCHAR(120),
ADD COLUMN "bemerkung" VARCHAR(1000),
ADD COLUMN "dinTyp" VARCHAR(40),
ADD COLUMN "erstzulassung" DATE,
ADD COLUMN "fahrgestellnummer" VARCHAR(30),
ADD COLUMN "gesamtmasse" INTEGER,
ADD COLUMN "indienststellung" DATE,
ADD COLUMN "kilometerstand" INTEGER,
ADD COLUMN "naechsteWartung" DATE,
ADD COLUMN "sitzplaetze" INTEGER,
ADD COLUMN "standortId" UUID,
ADD COLUMN "tankinhalt" INTEGER,
ADD COLUMN "uvvBis" DATE;
-- AlterTable
ALTER TABLE "geraet" ADD COLUMN "anschaffungspreis" DECIMAL(12,2),
ADD COLUMN "ausgesondertAm" DATE,
ADD COLUMN "ausgesondertGrund" VARCHAR(500),
ADD COLUMN "bemerkung" VARCHAR(1000),
ADD COLUMN "geraeteraumId" UUID,
ADD COLUMN "geraetetypId" UUID,
ADD COLUMN "lebensdauerBis" DATE,
ADD COLUMN "lebensdauerKategorie" "LebensdauerKategorie",
ADD COLUMN "letztePruefung" DATE,
ADD COLUMN "modell" VARCHAR(120),
ADD COLUMN "standortId" UUID;
-- CreateTable
CREATE TABLE "standort" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kuerzel" VARCHAR(20),
"art" "StandortArt" NOT NULL DEFAULT 'GERAETEHAUS',
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"koordinaten" geography(Point, 4326),
"bemerkung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "standort_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraeteraum" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"fahrzeugId" UUID NOT NULL,
"bezeichnung" VARCHAR(40) NOT NULL,
"beschreibung" VARCHAR(200),
"sortierung" INTEGER NOT NULL DEFAULT 0,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraeteraum_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "geraetetyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kategorie" "GeraeteKategorie" NOT NULL DEFAULT 'SONSTIGES',
"hersteller" VARCHAR(120),
"modell" VARCHAR(120),
"pruefintervallMonate" INTEGER,
"pruefvorschrift" VARCHAR(200),
"lebensdauerKategorie" "LebensdauerKategorie" NOT NULL DEFAULT 'KEINE',
"lebensdauerJahre" INTEGER,
"lebensdauerBezug" "LebensdauerBezug" NOT NULL DEFAULT 'INDIENSTSTELLUNG',
"vorwarnzeitMonate" INTEGER NOT NULL DEFAULT 3,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "geraetetyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "qualifikationstyp" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(160) NOT NULL,
"kuerzel" VARCHAR(20),
"art" "QualifikationsArt" NOT NULL DEFAULT 'LEHRGANG',
"gueltigkeitMonate" INTEGER,
"vorwarnzeitTage" INTEGER NOT NULL DEFAULT 90,
"einsatzrelevant" BOOLEAN NOT NULL DEFAULT false,
"beschreibung" VARCHAR(1000),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "qualifikationstyp_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "qualifikation" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"typId" UUID NOT NULL,
"erworbenAm" DATE NOT NULL,
"gueltigBis" DATE,
"ausstellendeStelle" VARCHAR(200),
"nachweisnummer" VARCHAR(80),
"urkundeDateiId" UUID,
"bestaetigtVonId" UUID,
"bestaetigtAm" TIMESTAMPTZ(3),
"bemerkung" VARCHAR(1000),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "qualifikation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "standort_organisationId_aktiv_idx" ON "standort"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "standort_organisationId_name_key" ON "standort"("organisationId", "name");
-- CreateIndex
CREATE INDEX "geraeteraum_organisationId_fahrzeugId_idx" ON "geraeteraum"("organisationId", "fahrzeugId");
-- CreateIndex
CREATE UNIQUE INDEX "geraeteraum_fahrzeugId_bezeichnung_key" ON "geraeteraum"("fahrzeugId", "bezeichnung");
-- CreateIndex
CREATE INDEX "geraetetyp_organisationId_kategorie_idx" ON "geraetetyp"("organisationId", "kategorie");
-- CreateIndex
CREATE INDEX "geraetetyp_organisationId_aktiv_idx" ON "geraetetyp"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "geraetetyp_organisationId_name_key" ON "geraetetyp"("organisationId", "name");
-- CreateIndex
CREATE INDEX "qualifikationstyp_organisationId_aktiv_idx" ON "qualifikationstyp"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "qualifikationstyp_organisationId_name_key" ON "qualifikationstyp"("organisationId", "name");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_personId_idx" ON "qualifikation"("organisationId", "personId");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_typId_idx" ON "qualifikation"("organisationId", "typId");
-- CreateIndex
CREATE INDEX "qualifikation_organisationId_gueltigBis_idx" ON "qualifikation"("organisationId", "gueltigBis");
-- CreateIndex
CREATE INDEX "fahrzeug_organisationId_tuevBis_idx" ON "fahrzeug"("organisationId", "tuevBis");
-- CreateIndex
CREATE UNIQUE INDEX "fahrzeug_organisationId_kennzeichen_key" ON "fahrzeug"("organisationId", "kennzeichen");
-- CreateIndex
CREATE INDEX "geraet_organisationId_lebensdauerBis_idx" ON "geraet"("organisationId", "lebensdauerBis");
-- CreateIndex
CREATE INDEX "geraet_organisationId_standortId_idx" ON "geraet"("organisationId", "standortId");
-- CreateIndex
CREATE INDEX "geraet_organisationId_geraetetypId_idx" ON "geraet"("organisationId", "geraetetypId");
-- CreateIndex
CREATE INDEX "geraet_organisationId_seriennummer_idx" ON "geraet"("organisationId", "seriennummer");
-- AddForeignKey
ALTER TABLE "standort" ADD CONSTRAINT "standort_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "fahrzeug" ADD CONSTRAINT "fahrzeug_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraeteraum" ADD CONSTRAINT "geraeteraum_fahrzeugId_fkey" FOREIGN KEY ("fahrzeugId") REFERENCES "fahrzeug"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraetetyp" ADD CONSTRAINT "geraetetyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraetetypId_fkey" FOREIGN KEY ("geraetetypId") REFERENCES "geraetetyp"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_standortId_fkey" FOREIGN KEY ("standortId") REFERENCES "standort"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_geraeteraumId_fkey" FOREIGN KEY ("geraeteraumId") REFERENCES "geraeteraum"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikationstyp" ADD CONSTRAINT "qualifikationstyp_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_typId_fkey" FOREIGN KEY ("typId") REFERENCES "qualifikationstyp"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "qualifikation" ADD CONSTRAINT "qualifikation_bestaetigtVonId_fkey" FOREIGN KEY ("bestaetigtVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,172 @@
-- =============================================================================
-- Phase 1: Mandantentrennung, Lagerungsregel und Suchindizes.
--
-- Die vorige Migration hat die Tabellen angelegt. Ohne Row Level Security wären
-- sie für die Anwendungsrolle offen — jede neue Tabelle muss deshalb dieselbe
-- Behandlung bekommen wie die aus Phase 0.
--
-- Dazu kommen zwei Dinge, die sich nur in der Datenbank sauber durchsetzen
-- lassen: die Regel, wo ein Gerät liegen darf, und schnelle Volltextsuche.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security für die neuen Tabellen
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'standort',
'geraeteraum',
'geraetetyp',
'qualifikationstyp',
'qualifikation'
]
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;
$$;
-- -----------------------------------------------------------------------------
-- Bestandsdaten auf die neue Lagerungsregel bringen.
--
-- Vor Phase 1 konnte ein Gerät nur einen Freitext-Lagerort haben. Damit die
-- Prüfbedingung weiter unten greift, braucht jedes Gerät ohne Fahrzeug jetzt
-- einen echten Standort. Je Wehr wird dafür ein Gerätehaus angelegt, sofern
-- noch keines existiert.
-- -----------------------------------------------------------------------------
INSERT INTO "standort" ("id", "organisationId", "name", "kuerzel", "art", "erstelltAm", "geaendertAm")
SELECT
gen_random_uuid(),
o."id",
'Gerätehaus',
'GH',
'GERAETEHAUS',
now(),
now()
FROM "organisation" o
WHERE NOT EXISTS (
SELECT 1 FROM "standort" s WHERE s."organisationId" = o."id"
);
UPDATE "geraet" g
SET "standortId" = (
SELECT s."id"
FROM "standort" s
WHERE s."organisationId" = g."organisationId"
AND s."art" = 'GERAETEHAUS'
ORDER BY s."erstelltAm"
LIMIT 1
)
WHERE g."fahrzeugId" IS NULL
AND g."standortId" IS NULL;
-- Fahrzeuge dem Gerätehaus zuordnen, sofern noch kein Standort gesetzt ist.
UPDATE "fahrzeug" f
SET "standortId" = (
SELECT s."id"
FROM "standort" s
WHERE s."organisationId" = f."organisationId"
AND s."art" = 'GERAETEHAUS'
ORDER BY s."erstelltAm"
LIMIT 1
)
WHERE f."standortId" IS NULL;
-- -----------------------------------------------------------------------------
-- Wo ein Gerät liegen darf.
--
-- Entweder es steht an einem Standort — dann ist kein Fahrzeug gesetzt. Oder es
-- ist auf einem Fahrzeug verlastet — dann ist kein Standort gesetzt, und der
-- Geräteraum darf angegeben werden, muss aber nicht (manches liegt lose in der
-- Kabine). Beides gleichzeitig oder gar nichts ist eine Falschangabe.
--
-- Dass ein angegebener Geräteraum auch wirklich zum angegebenen Fahrzeug
-- gehört, lässt sich als CHECK nicht ausdrücken — das prüft der Trigger
-- darunter.
-- -----------------------------------------------------------------------------
ALTER TABLE "geraet" ADD CONSTRAINT "geraet_lagerung_eindeutig" CHECK (
("standortId" IS NOT NULL AND "fahrzeugId" IS NULL AND "geraeteraumId" IS NULL)
OR ("standortId" IS NULL AND "fahrzeugId" IS NOT NULL)
);
CREATE OR REPLACE FUNCTION geraeteraum_gehoert_zum_fahrzeug()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
raum_fahrzeug uuid;
BEGIN
IF NEW."geraeteraumId" IS NULL THEN
RETURN NEW;
END IF;
SELECT "fahrzeugId" INTO raum_fahrzeug
FROM "geraeteraum"
WHERE "id" = NEW."geraeteraumId";
IF raum_fahrzeug IS DISTINCT FROM NEW."fahrzeugId" THEN
RAISE EXCEPTION
'Der Geraeteraum gehoert nicht zu dem angegebenen Fahrzeug.'
USING ERRCODE = 'check_violation';
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER geraet_raum_pruefen
BEFORE INSERT OR UPDATE OF "fahrzeugId", "geraeteraumId" ON "geraet"
FOR EACH ROW EXECUTE FUNCTION geraeteraum_gehoert_zum_fahrzeug();
-- -----------------------------------------------------------------------------
-- Volltextsuche.
--
-- Die Listen sollen auf Tippen reagieren, auch bei einigen tausend Geräten.
-- pg_trgm macht ILIKE '%text%' indexfähig — ohne diese Indizes läuft jede
-- Suche als vollständiger Tabellendurchlauf.
-- -----------------------------------------------------------------------------
CREATE INDEX "geraet_suche_bezeichnung" ON "geraet" USING gin ("bezeichnung" gin_trgm_ops);
CREATE INDEX "geraet_suche_inventarnummer" ON "geraet" USING gin ("inventarnummer" gin_trgm_ops);
CREATE INDEX "geraet_suche_seriennummer" ON "geraet" USING gin ("seriennummer" gin_trgm_ops);
CREATE INDEX "geraet_suche_hersteller" ON "geraet" USING gin ("hersteller" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_funkrufname" ON "fahrzeug" USING gin ("funkrufname" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_bezeichnung" ON "fahrzeug" USING gin ("bezeichnung" gin_trgm_ops);
CREATE INDEX "fahrzeug_suche_kennzeichen" ON "fahrzeug" USING gin ("kennzeichen" gin_trgm_ops);
CREATE INDEX "person_suche_nachname" ON "person" USING gin ("nachname" gin_trgm_ops);
CREATE INDEX "person_suche_vorname" ON "person" USING gin ("vorname" gin_trgm_ops);
CREATE INDEX "geraetetyp_suche_name" ON "geraetetyp" USING gin ("name" gin_trgm_ops);
CREATE INDEX "standort_suche_name" ON "standort" USING gin ("name" gin_trgm_ops);
-- Räumliche Abfragen auf Standorte, ab Phase 6 gebraucht.
CREATE INDEX "standort_koordinaten" ON "standort" USING gist ("koordinaten");
-- -----------------------------------------------------------------------------
-- Rechte für die Anwendungsrolle auf die neuen Tabellen.
-- -----------------------------------------------------------------------------
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 'GRANT EXECUTE ON FUNCTION aktuelle_organisation() TO florian_app';
-- Die Einschraenkungen aus Phase 0 gelten unveraendert weiter.
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
ELSE
RAISE NOTICE 'Rolle florian_app nicht vorhanden — Rechtevergabe uebersprungen.';
END IF;
END;
$$;
+315 -15
View File
@@ -161,6 +161,11 @@ model Organisation {
dateien Datei[] dateien Datei[]
fahrzeuge Fahrzeug[] fahrzeuge Fahrzeug[]
geraete Geraet[] geraete Geraet[]
standorte Standort[]
geraeteraeume Geraeteraum[]
geraetetypen Geraetetyp[]
qualifikationstypen Qualifikationstyp[]
qualifikationen Qualifikation[]
@@index([aktiv]) @@index([aktiv])
@@map("organisation") @@map("organisation")
@@ -224,6 +229,8 @@ model Person {
ausweisTokens AusweisToken[] ausweisTokens AusweisToken[]
auditEintraege AuditLog[] auditEintraege AuditLog[]
hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon") hochgeladeneDateien Datei[] @relation("DateiHochgeladenVon")
qualifikationen Qualifikation[] @relation("QualifikationInhaber")
bestaetigteQualifikationen Qualifikation[] @relation("QualifikationBestaetiger")
/// 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.
@@ -454,14 +461,119 @@ model Datei {
} }
// ============================================================================= // =============================================================================
// Vorgriff auf Phase 1 // Phase 1 — Stammdaten
// Diese beiden Modelle sind bewusst schmal gehalten. Sie existieren, damit das //
// Seed-Skript eine realistische Wehr aufbauen kann und die Mandantentrennung // Ordnungsprinzip:
// an echten Daten prüfbar ist. Phase 1 erweitert sie um Funkrufname-Systematik, // Standort wo etwas steht (Gerätehaus, Lager, Außenstelle)
// DIN-Typisierung, Geräteräume, Prüfintervalle und Lebensdauerkategorien. // Fahrzeug ein Einsatzfahrzeug, es hat Geräteräume
// Geraeteraum ein Fach am Fahrzeug (G1 … G6, Dachkasten, Mannschaftsraum)
// Geraetetyp die Bauart samt Prüfvorgabe — "Pressluftatmer PA 94"
// Geraet das einzelne Stück mit Inventarnummer
//
// Ein Gerät liegt ENTWEDER an einem Standort ODER verlastet in einem
// Geräteraum eines Fahrzeugs. Beides gleichzeitig oder gar nichts wäre eine
// Falschangabe; eine Prüfbedingung in der Datenbank verhindert das.
// ============================================================================= // =============================================================================
/// Ein Einsatzfahrzeug der Wehr. /// Art eines Standorts.
enum StandortArt {
GERAETEHAUS
LAGER
AUSSENSTELLE
WERKSTATT
SONSTIGES
}
/// Fachliche Einordnung eines Gerätetyps — bestimmt die Gruppierung in Listen.
enum GeraeteKategorie {
ATEMSCHUTZ
BELEUCHTUNG
BRANDBEKAEMPFUNG
ELEKTRO
FUNK
HYGIENE
MEDIZIN
MESSTECHNIK
PERSOENLICHE_SCHUTZAUSRUESTUNG
RETTUNG
SCHLAUCHMATERIAL
TECHNISCHE_HILFELEISTUNG
UMWELTSCHUTZ
WASSERFOERDERUNG
SONSTIGES
}
/// Lebensdauerkategorie. Diese Gruppen haben eine maximale Nutzungsdauer ab
/// Herstellungs- oder Indienststellungsdatum, unabhängig vom Zustand.
enum LebensdauerKategorie {
KEINE
HELM
SCHUTZKLEIDUNG
HANDSCHUHE
STIEFEL
SEIL_GURT
PRESSLUFTATMER
ATEMANSCHLUSS
DRUCKBEHAELTER
SCHLAUCH
LEITER
SONSTIGE
}
/// Woran sich die maximale Nutzungsdauer bemisst.
enum LebensdauerBezug {
HERSTELLUNG
INDIENSTSTELLUNG
}
/// Art einer Qualifikation.
enum QualifikationsArt {
LEHRGANG
BEFAEHIGUNG
UNTERSUCHUNG
FUEHRERSCHEIN
UNTERWEISUNG
SONSTIGES
}
// -----------------------------------------------------------------------------
/// Ein Ort, an dem Fahrzeuge stehen und Geräte lagern.
model Standort {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
/// Kurzzeichen für Listen und Etiketten, z. B. "GH" oder "LAG".
kuerzel String? @db.VarChar(20)
art StandortArt @default(GERAETEHAUS)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
/// Koordinaten als PostGIS-Punkt (WGS 84). Prisma kennt den Typ nicht,
/// deshalb Unsupported — gelesen und geschrieben wird per Rohabfrage.
/// Ab Phase 6 zeigt die Karte diese Punkte an.
koordinaten Unsupported("geography(Point, 4326)")?
bemerkung String? @db.VarChar(1000)
aktiv Boolean @default(true)
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)
fahrzeuge Fahrzeug[]
geraete Geraet[]
@@unique([organisationId, name])
@@index([organisationId, aktiv])
@@map("standort")
}
/// Ein Einsatzfahrzeug.
model Fahrzeug { model Fahrzeug {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid organisationId String @db.Uuid
@@ -470,11 +582,103 @@ model Fahrzeug {
funkrufname String @db.VarChar(120) funkrufname String @db.VarChar(120)
bezeichnung String @db.VarChar(120) bezeichnung String @db.VarChar(120)
art FahrzeugArt @default(LOESCHFAHRZEUG) art FahrzeugArt @default(LOESCHFAHRZEUG)
/// Typkurzzeichen nach DIN, z. B. "LF 20", "TSF-W", "DLA(K) 23/12".
dinTyp String? @db.VarChar(40)
kennzeichen String? @db.VarChar(20) kennzeichen String? @db.VarChar(20)
hersteller String? @db.VarChar(120) hersteller String? @db.VarChar(120)
aufbauhersteller String? @db.VarChar(120)
fahrgestellnummer String? @db.VarChar(30)
baujahr Int? baujahr Int?
erstzulassung DateTime? @db.Date
indienststellung DateTime? @db.Date
/// Sitzplätze für die Stärkeberechnung ab Phase 3.
sitzplaetze Int?
/// Löschwasser in Litern.
tankinhalt Int?
/// Zulässige Gesamtmasse in kg.
gesamtmasse Int?
kilometerstand Int?
/// Nächste Hauptuntersuchung. /// Nächste Hauptuntersuchung.
tuevBis DateTime? @db.Date tuevBis DateTime? @db.Date
/// Nächste Sicherheitsprüfung nach UVV.
uvvBis DateTime? @db.Date
naechsteWartung DateTime? @db.Date
standortId String? @db.Uuid
aktiv Boolean @default(true)
bemerkung String? @db.VarChar(1000)
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)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
geraeteraeume Geraeteraum[]
geraete Geraet[]
@@unique([organisationId, funkrufname])
@@unique([organisationId, kennzeichen])
@@index([organisationId, aktiv])
@@index([organisationId, tuevBis])
@@map("fahrzeug")
}
/// Ein Fach am Fahrzeug. Die Nummerierung folgt der Beladeordnung: G1 bis G6
/// an den Seiten, dazu Dachkasten, Heck und Mannschaftsraum.
model Geraeteraum {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
fahrzeugId String @db.Uuid
/// Kurzzeichen wie "G1", "GR", "Dach".
bezeichnung String @db.VarChar(40)
beschreibung String? @db.VarChar(200)
/// Reihenfolge in der Anzeige — entspricht der Beladeordnung.
sortierung Int @default(0)
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)
fahrzeug Fahrzeug @relation(fields: [fahrzeugId], references: [id], onDelete: Cascade)
geraete Geraet[]
@@unique([fahrzeugId, bezeichnung])
@@index([organisationId, fahrzeugId])
@@map("geraeteraum")
}
/// Die Bauart eines Geräts samt Prüf- und Lebensdauervorgabe. Was hier steht,
/// gilt als Vorgabe für jedes Gerät dieses Typs; einzelne Geräte dürfen davon
/// abweichen, wenn der Hersteller etwas anderes vorschreibt.
model Geraetetyp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
kategorie GeraeteKategorie @default(SONSTIGES)
hersteller String? @db.VarChar(120)
modell String? @db.VarChar(120)
/// Vorgabe für das Prüfintervall in Monaten.
pruefintervallMonate Int?
/// Fundstelle der Prüfvorschrift, z. B. "DGUV Vorschrift 49" oder
/// "DIN 14800-16".
pruefvorschrift String? @db.VarChar(200)
lebensdauerKategorie LebensdauerKategorie @default(KEINE)
/// Maximale Nutzungsdauer in Jahren.
lebensdauerJahre Int?
lebensdauerBezug LebensdauerBezug @default(INDIENSTSTELLUNG)
/// Wie viele Monate vor Ablauf gewarnt wird.
vorwarnzeitMonate Int @default(3)
beschreibung String? @db.VarChar(1000)
aktiv Boolean @default(true) aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3)
@@ -484,45 +688,141 @@ model Fahrzeug {
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraete Geraet[] geraete Geraet[]
@@unique([organisationId, funkrufname]) @@unique([organisationId, name])
@@index([organisationId, kategorie])
@@index([organisationId, aktiv]) @@index([organisationId, aktiv])
@@map("fahrzeug") @@map("geraetetyp")
} }
/// Ein prüfpflichtiges Gerät — Pressluftatmer, Schlauch, Leiter, Motorsäge. /// Ein einzelnes Gerät mit eigener Inventarnummer.
model Geraet { model Geraet {
id String @id @default(uuid()) @db.Uuid id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid organisationId String @db.Uuid
inventarnummer String @db.VarChar(60) inventarnummer String @db.VarChar(60)
bezeichnung String @db.VarChar(160) bezeichnung String @db.VarChar(160)
geraetetypId String? @db.Uuid
hersteller String? @db.VarChar(120) hersteller String? @db.VarChar(120)
modell String? @db.VarChar(120)
seriennummer String? @db.VarChar(120) seriennummer String? @db.VarChar(120)
herstellungsdatum DateTime? @db.Date herstellungsdatum DateTime? @db.Date
indienststellung DateTime? @db.Date indienststellung DateTime? @db.Date
anschaffungspreis Decimal? @db.Decimal(12, 2)
status GeraetStatus @default(EINSATZBEREIT) status GeraetStatus @default(EINSATZBEREIT)
/// Prüfintervall in Monaten aus Vorschrift oder Herstellerangabe.
pruefintervallMonate Int?
naechstePruefung DateTime? @db.Date
/// Maximale Nutzungsdauer in Jahren (Helme, Seile, Schutzkleidung).
lebensdauerJahre Int?
/// Verlastet auf einem Fahrzeug — oder im Gerätehaus, dann null. /// Prüfintervall in Monaten. Leer bedeutet: Vorgabe des Gerätetyps gilt.
pruefintervallMonate Int?
letztePruefung DateTime? @db.Date
naechstePruefung DateTime? @db.Date
/// Lebensdauer. Leer bedeutet ebenfalls: Vorgabe des Gerätetyps gilt.
lebensdauerKategorie LebensdauerKategorie?
lebensdauerJahre Int?
/// Errechnetes Ende der Nutzungsdauer, vom Server gesetzt.
lebensdauerBis DateTime? @db.Date
/// ENTWEDER standortId ODER fahrzeugId + geraeteraumId.
/// Eine Prüfbedingung in der Datenbank setzt das durch.
standortId String? @db.Uuid
fahrzeugId String? @db.Uuid fahrzeugId String? @db.Uuid
geraeteraumId String? @db.Uuid
/// Freitext für die genaue Stelle im Raum: "Regal 3, Fach B".
lagerort String? @db.VarChar(160) lagerort String? @db.VarChar(160)
ausgesondertAm DateTime? @db.Date
ausgesondertGrund String? @db.VarChar(500)
bemerkung String? @db.VarChar(1000)
erstelltAm DateTime @default(now()) @db.Timestamptz(3) erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3) geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3) geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade) organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
geraetetyp Geraetetyp? @relation(fields: [geraetetypId], references: [id], onDelete: SetNull)
standort Standort? @relation(fields: [standortId], references: [id], onDelete: SetNull)
fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull) fahrzeug Fahrzeug? @relation(fields: [fahrzeugId], references: [id], onDelete: SetNull)
geraeteraum Geraeteraum? @relation(fields: [geraeteraumId], references: [id], onDelete: SetNull)
@@unique([organisationId, inventarnummer]) @@unique([organisationId, inventarnummer])
@@index([organisationId, status]) @@index([organisationId, status])
@@index([organisationId, naechstePruefung]) @@index([organisationId, naechstePruefung])
@@index([organisationId, lebensdauerBis])
@@index([organisationId, fahrzeugId]) @@index([organisationId, fahrzeugId])
@@index([organisationId, standortId])
@@index([organisationId, geraetetypId])
@@index([organisationId, seriennummer])
@@map("geraet") @@map("geraet")
} }
/// Die Art einer Qualifikation — Lehrgang, Untersuchung, Führerschein.
model Qualifikationstyp {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(160)
/// Kurzzeichen für Listen, z. B. "MA", "AGT", "G26.3", "C1".
kuerzel String? @db.VarChar(20)
art QualifikationsArt @default(LEHRGANG)
/// Gültigkeit in Monaten. Leer bedeutet: gilt unbefristet.
gueltigkeitMonate Int?
/// Wie viele Tage vor Ablauf gewarnt wird.
vorwarnzeitTage Int @default(90)
/// Voraussetzung für den Einsatzdienst — wird im Dashboard hervorgehoben.
einsatzrelevant Boolean @default(false)
beschreibung String? @db.VarChar(1000)
aktiv Boolean @default(true)
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)
qualifikationen Qualifikation[]
@@unique([organisationId, name])
@@index([organisationId, aktiv])
@@map("qualifikationstyp")
}
/// Eine erworbene Qualifikation einer Person.
model Qualifikation {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
typId String @db.Uuid
erworbenAm DateTime @db.Date
/// Ablaufdatum. Wird beim Speichern aus gueltigkeitMonate errechnet, kann
/// aber überschrieben werden — auf einer Urkunde steht manchmal etwas
/// anderes als die Regel hergibt.
gueltigBis DateTime? @db.Date
ausstellendeStelle String? @db.VarChar(200)
/// Lehrgangs- oder Urkundennummer.
nachweisnummer String? @db.VarChar(80)
/// Eingescannte Urkunde, liegt in MinIO.
urkundeDateiId String? @db.Uuid
bestaetigtVonId String? @db.Uuid
bestaetigtAm DateTime? @db.Timestamptz(3)
bemerkung String? @db.VarChar(1000)
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)
person Person @relation("QualifikationInhaber", fields: [personId], references: [id], onDelete: Cascade)
typ Qualifikationstyp @relation(fields: [typId], references: [id], onDelete: Restrict)
bestaetigtVon Person? @relation("QualifikationBestaetiger", fields: [bestaetigtVonId], references: [id], onDelete: SetNull)
@@index([organisationId, personId])
@@index([organisationId, typId])
@@index([organisationId, gueltigBis])
@@map("qualifikation")
}
+597 -60
View File
@@ -1,8 +1,11 @@
/** /**
* Seed — legt eine vollständige Beispielwehr an. * Seed — legt eine vollständige Beispielwehr an.
* *
* Inhalt: Berechtigungskatalog, 9 Systemrollen, 15 Personen mit Rollen, * Inhalt: Berechtigungskatalog, 9 Systemrollen, 16 Personen mit Rollen und
* 3 Fahrzeuge, 40 Geräte, PIN und RFID-Ausweise für das Terminal. * Terminal-Ausweisen, 3 Standorte, 3 Fahrzeuge samt Geräteräumen,
* 14 Gerätetypen mit echten Prüfvorgaben, 45 Geräte mit QR-fähigen
* Inventarnummern, 14 Qualifikationstypen und die Qualifikationen der
* Mannschaft.
* *
* 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
@@ -15,12 +18,17 @@
*/ */
import "dotenv/config"; import "dotenv/config";
import { import {
AusweisTokenTyp,
Dienstgrad, Dienstgrad,
FahrzeugArt, FahrzeugArt,
GeraeteKategorie,
GeraetStatus, GeraetStatus,
LebensdauerBezug,
LebensdauerKategorie,
PrismaClient, PrismaClient,
QualifikationsArt,
RolleSchluessel, RolleSchluessel,
AusweisTokenTyp, StandortArt,
} from "@prisma/client"; } from "@prisma/client";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -49,6 +57,13 @@ function plusMonate(basis: Date, monate: number): Date {
return neu; return neu;
} }
/** Verschiebt ein Datum um Jahre — für Lebensdauern. */
function plusJahre(basis: Date, jahre: number): Date {
const neu = new Date(basis);
neu.setUTCFullYear(neu.getUTCFullYear() + jahre);
return neu;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Berechtigungskatalog // Berechtigungskatalog
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -434,69 +449,294 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Fahrzeuge und Geräte // Standorte
// -----------------------------------------------------------------------------
async function standorteAnlegen(organisationId: string) {
const vorlagen = [
{
name: "Gerätehaus",
kuerzel: "GH",
art: StandortArt.GERAETEHAUS,
strasse: "Hauptstraße 12",
plz: "85354",
ort: "Musterhausen",
},
{
name: "Lager Bauhof",
kuerzel: "LAG",
art: StandortArt.LAGER,
strasse: "Industriestraße 4",
plz: "85354",
ort: "Musterhausen",
bemerkung: "Reserve und Ersatzteile, Schlüssel beim Bauhof",
},
{
name: "Atemschutzwerkstatt",
kuerzel: "ASW",
art: StandortArt.WERKSTATT,
strasse: "Hauptstraße 12",
plz: "85354",
ort: "Musterhausen",
bemerkung: "Im Gerätehaus, Untergeschoss",
},
];
const karte = new Map<string, string>();
for (const vorlage of vorlagen) {
const standort = await prisma.standort.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: { organisationId, ...vorlage },
});
karte.set(vorlage.name, standort.id);
}
log(`Standorte: ${karte.size}`);
return karte;
}
// -----------------------------------------------------------------------------
// Gerätetypen
// -----------------------------------------------------------------------------
interface TypVorlage {
name: string;
kategorie: GeraeteKategorie;
hersteller: string;
pruefintervallMonate: number;
pruefvorschrift?: string;
lebensdauerKategorie?: LebensdauerKategorie;
lebensdauerJahre?: number;
lebensdauerBezug?: LebensdauerBezug;
}
/**
* Prüfintervalle und Lebensdauern nach den üblichen Vorgaben. Sie sind hier
* bewusst konkret hinterlegt — eine leere Beispielwehr zeigt nicht, worum es
* bei diesem Programm eigentlich geht.
*/
const GERAETETYPEN: TypVorlage[] = [
{
name: "Pressluftatmer PA 94",
kategorie: GeraeteKategorie.ATEMSCHUTZ,
hersteller: "Dräger",
pruefintervallMonate: 12,
pruefvorschrift: "DGUV Regel 112-190, vfdb 0804",
lebensdauerKategorie: LebensdauerKategorie.PRESSLUFTATMER,
lebensdauerJahre: 20,
},
{
name: "Atemschutzmaske FPS 7000",
kategorie: GeraeteKategorie.ATEMSCHUTZ,
hersteller: "Dräger",
pruefintervallMonate: 6,
pruefvorschrift: "DGUV Regel 112-190",
lebensdauerKategorie: LebensdauerKategorie.ATEMANSCHLUSS,
lebensdauerJahre: 10,
},
{
name: "Atemluftflasche 6,8 l / 300 bar",
kategorie: GeraeteKategorie.ATEMSCHUTZ,
hersteller: "Luxfer",
pruefintervallMonate: 60,
pruefvorschrift: "Betriebssicherheitsverordnung, wiederkehrende Prüfung",
lebensdauerKategorie: LebensdauerKategorie.DRUCKBEHAELTER,
lebensdauerJahre: 20,
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
},
{
name: "Feuerwehrhelm F220",
kategorie: GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG,
hersteller: "Schuberth",
pruefintervallMonate: 12,
pruefvorschrift: "DGUV Information 205-014",
lebensdauerKategorie: LebensdauerKategorie.HELM,
lebensdauerJahre: 15,
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
},
{
name: "Feuerwehrleine 30 m",
kategorie: GeraeteKategorie.RETTUNG,
hersteller: "Gustav Bauer",
pruefintervallMonate: 12,
pruefvorschrift: "DIN 14920, DGUV Grundsatz 305-002",
lebensdauerKategorie: LebensdauerKategorie.SEIL_GURT,
lebensdauerJahre: 8,
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
},
{
name: "Steckleiterteil",
kategorie: GeraeteKategorie.RETTUNG,
hersteller: "Iturri",
pruefintervallMonate: 12,
pruefvorschrift: "DIN 14711, DGUV Information 208-016",
lebensdauerKategorie: LebensdauerKategorie.LEITER,
lebensdauerJahre: 25,
},
{
name: "Tragkraftspritze PFPN 10-1000",
kategorie: GeraeteKategorie.WASSERFOERDERUNG,
hersteller: "Rosenbauer",
pruefintervallMonate: 12,
pruefvorschrift: "DIN 14420",
},
{
name: "Hydraulischer Rettungssatz",
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
hersteller: "Weber Rescue",
pruefintervallMonate: 12,
pruefvorschrift: "DGUV Grundsatz 305-002",
},
{
name: "Motorkettensäge MS 261",
kategorie: GeraeteKategorie.TECHNISCHE_HILFELEISTUNG,
hersteller: "Stihl",
pruefintervallMonate: 12,
pruefvorschrift: "DGUV Vorschrift 3, Betriebsanleitung",
},
{
name: "Stromerzeuger 8 kVA",
kategorie: GeraeteKategorie.ELEKTRO,
hersteller: "Endress",
pruefintervallMonate: 12,
pruefvorschrift: "DGUV Vorschrift 3",
},
{
name: "Wärmebildkamera",
kategorie: GeraeteKategorie.MESSTECHNIK,
hersteller: "Dräger",
pruefintervallMonate: 12,
},
{
name: "Handsprechfunkgerät TPH900",
kategorie: GeraeteKategorie.FUNK,
hersteller: "Airbus",
pruefintervallMonate: 24,
},
{
name: "B-Druckschlauch 20 m",
kategorie: GeraeteKategorie.SCHLAUCHMATERIAL,
hersteller: "Ziegler",
pruefintervallMonate: 12,
pruefvorschrift: "DIN 14811",
lebensdauerKategorie: LebensdauerKategorie.SCHLAUCH,
lebensdauerJahre: 10,
lebensdauerBezug: LebensdauerBezug.HERSTELLUNG,
},
{
name: "Überdrucklüfter",
kategorie: GeraeteKategorie.BRANDBEKAEMPFUNG,
hersteller: "Leader",
pruefintervallMonate: 12,
},
];
async function geraetetypenAnlegen(organisationId: string) {
const karte = new Map<string, string>();
for (const vorlage of GERAETETYPEN) {
const typ = await prisma.geraetetyp.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: {
organisationId,
name: vorlage.name,
kategorie: vorlage.kategorie,
hersteller: vorlage.hersteller,
pruefintervallMonate: vorlage.pruefintervallMonate,
pruefvorschrift: vorlage.pruefvorschrift ?? null,
lebensdauerKategorie: vorlage.lebensdauerKategorie ?? LebensdauerKategorie.KEINE,
lebensdauerJahre: vorlage.lebensdauerJahre ?? null,
lebensdauerBezug: vorlage.lebensdauerBezug ?? LebensdauerBezug.INDIENSTSTELLUNG,
},
});
karte.set(vorlage.name, typ.id);
}
log(`Gerätetypen: ${karte.size}`);
return karte;
}
// -----------------------------------------------------------------------------
// Fahrzeuge samt Geräteräumen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
const FAHRZEUGE = [ const FAHRZEUGE = [
{ {
funkrufname: "Florian Musterhausen 40/1", funkrufname: "Florian Musterhausen 40/1",
bezeichnung: "LF 20 (Löschgruppenfahrzeug)", bezeichnung: "Löschgruppenfahrzeug",
dinTyp: "LF 20",
art: FahrzeugArt.LOESCHFAHRZEUG, art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 401", kennzeichen: "FS-MU 401",
hersteller: "MAN / Rosenbauer", hersteller: "MAN",
aufbauhersteller: "Rosenbauer",
fahrgestellnummer: "WMAN12ZZ8JY123456",
baujahr: 2018, baujahr: 2018,
erstzulassung: datum(2018, 6, 14),
indienststellung: datum(2018, 7, 1),
sitzplaetze: 9,
tankinhalt: 2000,
gesamtmasse: 16_000,
tuevBis: datum(2027, 5, 31), tuevBis: datum(2027, 5, 31),
uvvBis: datum(2026, 11, 30),
}, },
{ {
funkrufname: "Florian Musterhausen 42/1", funkrufname: "Florian Musterhausen 42/1",
bezeichnung: "TSF-W (Tragkraftspritzenfahrzeug Wasser)", bezeichnung: "Tragkraftspritzenfahrzeug Wasser",
dinTyp: "TSF-W",
art: FahrzeugArt.LOESCHFAHRZEUG, art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "FS-MU 421", kennzeichen: "FS-MU 421",
hersteller: "Mercedes-Benz / Ziegler", hersteller: "Mercedes-Benz",
aufbauhersteller: "Ziegler",
fahrgestellnummer: "WDB90661310123789",
baujahr: 2011, baujahr: 2011,
erstzulassung: datum(2011, 9, 20),
indienststellung: datum(2011, 10, 15),
sitzplaetze: 6,
tankinhalt: 750,
gesamtmasse: 7500,
tuevBis: datum(2026, 11, 30), tuevBis: datum(2026, 11, 30),
uvvBis: datum(2026, 10, 15),
}, },
{ {
funkrufname: "Florian Musterhausen 11/1", funkrufname: "Florian Musterhausen 11/1",
bezeichnung: "MTW (Mannschaftstransportwagen)", bezeichnung: "Mannschaftstransportwagen",
dinTyp: "MTW",
art: FahrzeugArt.MANNSCHAFTSTRANSPORT, art: FahrzeugArt.MANNSCHAFTSTRANSPORT,
kennzeichen: "FS-MU 111", kennzeichen: "FS-MU 111",
hersteller: "Volkswagen", hersteller: "Volkswagen",
fahrgestellnummer: "WV2ZZZ7HZMH098765",
baujahr: 2021, baujahr: 2021,
erstzulassung: datum(2021, 3, 8),
indienststellung: datum(2021, 3, 15),
sitzplaetze: 9,
gesamtmasse: 3500,
tuevBis: datum(2027, 3, 31), tuevBis: datum(2027, 3, 31),
}, },
]; ];
/** /** Beladeordnung. Der MTW hat weniger Fächer als ein Löschfahrzeug. */
* 40 Geräte mit realistischen Prüfintervallen. Die Fristen sind bewusst const RAEUME_GROSS = [
* gemischt: einige liegen in der Vergangenheit, damit der Status { bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
* "Prüfung fällig" in der Oberfläche auch sichtbar ist. { bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
*/ { bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
const GERAETE_VORLAGEN: Array<{ { bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
bezeichnung: string; { bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
hersteller: string; { bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
anzahl: number; { bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
intervallMonate: number; { bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
lebensdauerJahre?: number; { bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
aufFahrzeug?: number;
lagerort?: string;
}> = [
{ bezeichnung: "Pressluftatmer PA 94", hersteller: "Dräger", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Atemschutzmaske FPS 7000", hersteller: "Dräger", anzahl: 6, intervallMonate: 6, lebensdauerJahre: 10, lagerort: "Atemschutzwerkstatt" },
{ bezeichnung: "Atemluftflasche 6,8 l / 300 bar", hersteller: "Luxfer", anzahl: 8, intervallMonate: 60, lebensdauerJahre: 20, aufFahrzeug: 0 },
{ bezeichnung: "Feuerwehrhelm F220", hersteller: "Schuberth", anzahl: 6, intervallMonate: 12, lebensdauerJahre: 15, lagerort: "Umkleide" },
{ bezeichnung: "Feuerwehrleine 30 m", hersteller: "Gustav Bauer", anzahl: 3, intervallMonate: 12, lebensdauerJahre: 8, aufFahrzeug: 0 },
{ bezeichnung: "Steckleiterteil", hersteller: "Iturri", anzahl: 4, intervallMonate: 12, lebensdauerJahre: 25, aufFahrzeug: 0 },
{ bezeichnung: "Tragkraftspritze PFPN 10-1000", hersteller: "Rosenbauer", anzahl: 1, intervallMonate: 12, aufFahrzeug: 1 },
{ bezeichnung: "Hydraulischer Rettungssatz", hersteller: "Weber Rescue", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Motorkettensäge MS 261", hersteller: "Stihl", anzahl: 2, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Stromerzeuger 8 kVA", hersteller: "Endress", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Wärmebildkamera", hersteller: "Dräger", anzahl: 1, intervallMonate: 12, aufFahrzeug: 0 },
{ bezeichnung: "Handsprechfunkgerät TPH900", hersteller: "Airbus", anzahl: 3, intervallMonate: 24, lagerort: "Funkraum" },
]; ];
async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise<void> { const RAEUME_KLEIN = [
const fahrzeugIds: string[] = []; { bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 10 },
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 20 },
];
async function fahrzeugeAnlegen(organisationId: string, standorte: Map<string, string>) {
const fahrzeuge: Array<{ id: string; funkrufname: string; raeume: Map<string, string> }> = [];
for (const vorlage of FAHRZEUGE) { for (const vorlage of FAHRZEUGE) {
const fahrzeug = await prisma.fahrzeug.upsert({ const fahrzeug = await prisma.fahrzeug.upsert({
@@ -504,52 +744,141 @@ async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise<void>
organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname }, organisationId_funkrufname: { organisationId, funkrufname: vorlage.funkrufname },
}, },
update: {}, update: {},
create: { organisationId, ...vorlage }, create: {
organisationId,
...vorlage,
standortId: standorte.get("Gerätehaus") ?? null,
},
}); });
fahrzeugIds.push(fahrzeug.id);
const vorlagen =
vorlage.art === FahrzeugArt.MANNSCHAFTSTRANSPORT ? RAEUME_KLEIN : RAEUME_GROSS;
const raeume = new Map<string, string>();
for (const raumVorlage of vorlagen) {
const raum = await prisma.geraeteraum.upsert({
where: {
fahrzeugId_bezeichnung: {
fahrzeugId: fahrzeug.id,
bezeichnung: raumVorlage.bezeichnung,
},
},
update: {},
create: { organisationId, fahrzeugId: fahrzeug.id, ...raumVorlage },
});
raeume.set(raumVorlage.bezeichnung, raum.id);
} }
log(`Fahrzeuge: ${fahrzeugIds.length}`); fahrzeuge.push({ id: fahrzeug.id, funkrufname: fahrzeug.funkrufname, raeume });
}
const raeumeGesamt = fahrzeuge.reduce((summe, f) => summe + f.raeume.size, 0);
log(`Fahrzeuge: ${fahrzeuge.length}, Geräteräume: ${raeumeGesamt}`);
return fahrzeuge;
}
// -----------------------------------------------------------------------------
// Geräte
// -----------------------------------------------------------------------------
interface GeraetVorlage {
typ: string;
anzahl: number;
/** Index in FAHRZEUGE, oder null für einen Standort. */
aufFahrzeug: number | null;
raum?: string;
standort?: string;
lagerort?: string;
}
const GERAETE: GeraetVorlage[] = [
{ typ: "Pressluftatmer PA 94", anzahl: 4, aufFahrzeug: 0, raum: "MR" },
{ typ: "Atemschutzmaske FPS 7000", anzahl: 6, aufFahrzeug: null, standort: "Atemschutzwerkstatt", lagerort: "Schrank 1" },
{ typ: "Atemluftflasche 6,8 l / 300 bar", anzahl: 8, aufFahrzeug: 0, raum: "G4" },
{ typ: "Feuerwehrhelm F220", anzahl: 6, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Umkleide, Spind-Reihe A" },
{ typ: "Feuerwehrleine 30 m", anzahl: 3, aufFahrzeug: 0, raum: "G2" },
{ typ: "Steckleiterteil", anzahl: 4, aufFahrzeug: 0, raum: "Dach" },
{ typ: "Tragkraftspritze PFPN 10-1000", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
{ typ: "Hydraulischer Rettungssatz", anzahl: 1, aufFahrzeug: 0, raum: "G3" },
{ typ: "Motorkettensäge MS 261", anzahl: 2, aufFahrzeug: 0, raum: "G5" },
{ typ: "Stromerzeuger 8 kVA", anzahl: 1, aufFahrzeug: 0, raum: "G6" },
{ typ: "Wärmebildkamera", anzahl: 1, aufFahrzeug: 0, raum: "MR" },
{ typ: "Handsprechfunkgerät TPH900", anzahl: 3, aufFahrzeug: null, standort: "Gerätehaus", lagerort: "Funkraum, Ladeschale" },
{ typ: "B-Druckschlauch 20 m", anzahl: 8, aufFahrzeug: 0, raum: "G1" },
{ typ: "Überdrucklüfter", anzahl: 1, aufFahrzeug: 1, raum: "GR" },
];
async function geraeteAnlegen(
organisationId: string,
typen: Map<string, string>,
fahrzeuge: Array<{ id: string; raeume: Map<string, string> }>,
standorte: Map<string, string>,
) {
const heute = new Date(); const heute = new Date();
let laufend = 1; let laufend = 1;
let angelegt = 0; let angelegt = 0;
for (const vorlage of GERAETE_VORLAGEN) { for (const vorlage of GERAETE) {
const typId = typen.get(vorlage.typ);
const typVorlage = GERAETETYPEN.find((t) => t.name === vorlage.typ);
if (!typId || !typVorlage) continue;
for (let index = 0; index < vorlage.anzahl; index += 1) { for (let index = 0; index < vorlage.anzahl; index += 1) {
const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`; const inventarnummer = `INV-${String(laufend).padStart(4, "0")}`;
// Ein Drittel der Geräte bekommt eine bereits überschrittene Frist, // Ein Teil der Geräte bekommt eine überschrittene Frist, damit die
// damit die Statusanzeige nicht nur grün ist. // Statusanzeige nicht durchgehend grün ist und man sieht, wofür sie da
const ueberfaellig = laufend % 3 === 0; // ist.
const indienststellung = datum(2015 + (laufend % 9), ((laufend % 12) + 1), 15); const ueberfaellig = laufend % 4 === 0;
const naechstePruefung = ueberfaellig const indienststellung = datum(2015 + (laufend % 9), (laufend % 12) + 1, 15);
? plusMonate(heute, -(1 + (laufend % 5))) const herstellung = datum(indienststellung.getUTCFullYear() - 1, 6, 1);
: plusMonate(heute, 1 + (laufend % vorlage.intervallMonate || 1));
const status = ueberfaellig const letztePruefung = ueberfaellig
? GeraetStatus.PRUEFUNG_FAELLIG ? plusMonate(heute, -(typVorlage.pruefintervallMonate + 2))
: laufend % 17 === 0 : plusMonate(heute, -(laufend % typVorlage.pruefintervallMonate));
const naechstePruefung = plusMonate(letztePruefung, typVorlage.pruefintervallMonate);
const lebensdauerBis = typVorlage.lebensdauerJahre
? plusJahre(
typVorlage.lebensdauerBezug === LebensdauerBezug.HERSTELLUNG
? herstellung
: indienststellung,
typVorlage.lebensdauerJahre,
)
: null;
const status =
lebensdauerBis && lebensdauerBis < heute
? GeraetStatus.GESPERRT ? GeraetStatus.GESPERRT
: naechstePruefung < heute
? GeraetStatus.PRUEFUNG_FAELLIG
: laufend % 19 === 0
? GeraetStatus.IN_REPARATUR
: GeraetStatus.EINSATZBEREIT; : GeraetStatus.EINSATZBEREIT;
const fahrzeug = vorlage.aufFahrzeug !== null ? fahrzeuge[vorlage.aufFahrzeug] : null;
await prisma.geraet.upsert({ await prisma.geraet.upsert({
where: { organisationId_inventarnummer: { organisationId, inventarnummer } }, where: { organisationId_inventarnummer: { organisationId, inventarnummer } },
update: {}, update: {},
create: { create: {
organisationId, organisationId,
inventarnummer, inventarnummer,
bezeichnung: vorlage.bezeichnung, bezeichnung: vorlage.typ,
hersteller: vorlage.hersteller, geraetetypId: typId,
seriennummer: `SN-${vorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`, hersteller: typVorlage.hersteller,
herstellungsdatum: datum(indienststellung.getUTCFullYear() - 1, 6, 1), seriennummer: `SN-${typVorlage.hersteller.slice(0, 3).toUpperCase()}-${100000 + laufend}`,
herstellungsdatum: herstellung,
indienststellung, indienststellung,
anschaffungspreis: preisFuer(typVorlage.kategorie, laufend),
status, status,
pruefintervallMonate: vorlage.intervallMonate, letztePruefung,
naechstePruefung, naechstePruefung,
lebensdauerJahre: vorlage.lebensdauerJahre ?? null, lebensdauerBis,
fahrzeugId: fahrzeugId: fahrzeug?.id ?? null,
vorlage.aufFahrzeug !== undefined ? (fahrzeugIds[vorlage.aufFahrzeug] ?? null) : null, geraeteraumId: fahrzeug && vorlage.raum ? (fahrzeug.raeume.get(vorlage.raum) ?? null) : null,
standortId: fahrzeug ? null : (standorte.get(vorlage.standort ?? "Gerätehaus") ?? null),
lagerort: vorlage.lagerort ?? null, lagerort: vorlage.lagerort ?? null,
}, },
}); });
@@ -562,6 +891,207 @@ async function fahrzeugeUndGeraeteAnlegen(organisationId: string): Promise<void>
log(`Geräte: ${angelegt}`); log(`Geräte: ${angelegt}`);
} }
/** Grobe Anschaffungspreise, damit Auswertungen später etwas zu rechnen haben. */
function preisFuer(kategorie: GeraeteKategorie, streuung: number): number {
const grundpreise: Partial<Record<GeraeteKategorie, number>> = {
[GeraeteKategorie.ATEMSCHUTZ]: 1800,
[GeraeteKategorie.RETTUNG]: 450,
[GeraeteKategorie.TECHNISCHE_HILFELEISTUNG]: 6500,
[GeraeteKategorie.ELEKTRO]: 3200,
[GeraeteKategorie.MESSTECHNIK]: 4800,
[GeraeteKategorie.FUNK]: 1400,
[GeraeteKategorie.SCHLAUCHMATERIAL]: 120,
[GeraeteKategorie.WASSERFOERDERUNG]: 9500,
[GeraeteKategorie.PERSOENLICHE_SCHUTZAUSRUESTUNG]: 320,
[GeraeteKategorie.BRANDBEKAEMPFUNG]: 2100,
};
const grund = grundpreise[kategorie] ?? 250;
return Math.round((grund + (streuung % 7) * grund * 0.03) * 100) / 100;
}
// -----------------------------------------------------------------------------
// Qualifikationen
// -----------------------------------------------------------------------------
interface QualiTypVorlage {
name: string;
kuerzel: string;
art: QualifikationsArt;
gueltigkeitMonate?: number;
vorwarnzeitTage?: number;
einsatzrelevant?: boolean;
}
const QUALIFIKATIONSTYPEN: QualiTypVorlage[] = [
{ name: "Truppmann Teil 1 (MTA Basismodul)", kuerzel: "TM1", art: QualifikationsArt.LEHRGANG, einsatzrelevant: true },
{ name: "Truppmann Teil 2", kuerzel: "TM2", art: QualifikationsArt.LEHRGANG },
{ name: "Truppführer", kuerzel: "TF", art: QualifikationsArt.LEHRGANG },
{ name: "Gruppenführer", kuerzel: "GF", art: QualifikationsArt.LEHRGANG },
{ name: "Zugführer", kuerzel: "ZF", art: QualifikationsArt.LEHRGANG },
{ name: "Maschinist für Löschfahrzeuge", kuerzel: "MA", art: QualifikationsArt.LEHRGANG },
{ name: "Sprechfunker", kuerzel: "SF", art: QualifikationsArt.LEHRGANG },
{
name: "Atemschutzgeräteträger",
kuerzel: "AGT",
art: QualifikationsArt.BEFAEHIGUNG,
gueltigkeitMonate: 12,
vorwarnzeitTage: 60,
einsatzrelevant: true,
},
{
name: "Arbeitsmedizinische Vorsorge G26.3",
kuerzel: "G26.3",
art: QualifikationsArt.UNTERSUCHUNG,
gueltigkeitMonate: 36,
vorwarnzeitTage: 120,
einsatzrelevant: true,
},
{
name: "Belastungsübung Atemschutz",
kuerzel: "BÜ",
art: QualifikationsArt.UNTERWEISUNG,
gueltigkeitMonate: 12,
vorwarnzeitTage: 60,
einsatzrelevant: true,
},
{ name: "Motorsägenführer", kuerzel: "MS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 60 },
{ name: "Führerschein C/CE", kuerzel: "C/CE", art: QualifikationsArt.FUEHRERSCHEIN, gueltigkeitMonate: 60, vorwarnzeitTage: 90 },
{ name: "Erste Hilfe", kuerzel: "EH", art: QualifikationsArt.UNTERWEISUNG, gueltigkeitMonate: 24 },
{ name: "Absturzsicherung", kuerzel: "AS", art: QualifikationsArt.BEFAEHIGUNG, gueltigkeitMonate: 12 },
];
async function qualifikationstypenAnlegen(organisationId: string) {
const karte = new Map<string, string>();
for (const vorlage of QUALIFIKATIONSTYPEN) {
const typ = await prisma.qualifikationstyp.upsert({
where: { organisationId_name: { organisationId, name: vorlage.name } },
update: {},
create: {
organisationId,
name: vorlage.name,
kuerzel: vorlage.kuerzel,
art: vorlage.art,
gueltigkeitMonate: vorlage.gueltigkeitMonate ?? null,
vorwarnzeitTage: vorlage.vorwarnzeitTage ?? 90,
einsatzrelevant: vorlage.einsatzrelevant ?? false,
},
});
karte.set(vorlage.name, typ.id);
}
log(`Qualifikationstypen: ${karte.size}`);
return karte;
}
/**
* Verteilt Qualifikationen auf die Mannschaft.
*
* Ein Teil läuft absichtlich demnächst ab oder ist bereits abgelaufen — sonst
* bliebe die Ablaufwarnung in der Beispielwehr leer, und man sähe nicht, wofür
* sie da ist.
*/
async function qualifikationenAnlegen(organisationId: string, typen: Map<string, string>) {
const personen = await prisma.person.findMany({
where: { organisationId, geloeschtAm: null, abteilung: { not: "Verwaltung" } },
orderBy: { mitgliedsnummer: "asc" },
select: { id: true, mitgliedsnummer: true, eintrittsdatum: true },
});
const heute = new Date();
let angelegt = 0;
for (const [index, person] of personen.entries()) {
const eintritt = person.eintrittsdatum ?? datum(2015, 1, 1);
/** Grundausbildung hat jeder, weitere je nach Dienstalter. */
const zuweisungen: Array<{ typ: string; erworben: Date; gueltigBis?: Date | null }> = [
{ typ: "Truppmann Teil 1 (MTA Basismodul)", erworben: plusMonate(eintritt, 6) },
];
if (index < 12) {
zuweisungen.push({ typ: "Truppmann Teil 2", erworben: plusMonate(eintritt, 24) });
zuweisungen.push({ typ: "Sprechfunker", erworben: plusMonate(eintritt, 12) });
}
if (index < 8) {
zuweisungen.push({ typ: "Maschinist für Löschfahrzeuge", erworben: plusMonate(eintritt, 36) });
}
if (index < 6) {
zuweisungen.push({ typ: "Truppführer", erworben: plusMonate(eintritt, 48) });
}
if (index < 3) {
zuweisungen.push({ typ: "Gruppenführer", erworben: plusMonate(eintritt, 72) });
}
// Atemschutz: die Fristen werden gestreut, damit die Ampel etwas zeigt.
if (index < 9) {
const versatz = [-2, -1, 1, 2, 3, 5, 8, 10, 11][index] ?? 6;
zuweisungen.push({
typ: "Atemschutzgeräteträger",
erworben: plusMonate(heute, versatz - 12),
gueltigBis: plusMonate(heute, versatz),
});
zuweisungen.push({
typ: "Arbeitsmedizinische Vorsorge G26.3",
erworben: plusMonate(heute, versatz - 36),
gueltigBis: plusMonate(heute, versatz),
});
zuweisungen.push({
typ: "Belastungsübung Atemschutz",
erworben: plusMonate(heute, versatz - 12),
gueltigBis: plusMonate(heute, versatz),
});
}
if (index < 5) {
zuweisungen.push({
typ: "Führerschein C/CE",
erworben: plusMonate(eintritt, 12),
gueltigBis: plusMonate(heute, 6 + index * 4),
});
}
if (index < 4) {
zuweisungen.push({
typ: "Motorsägenführer",
erworben: plusMonate(heute, -30),
gueltigBis: plusMonate(heute, 30),
});
}
zuweisungen.push({
typ: "Erste Hilfe",
erworben: plusMonate(heute, -20 - index),
gueltigBis: plusMonate(heute, 4 - index),
});
for (const zuweisung of zuweisungen) {
const typId = typen.get(zuweisung.typ);
if (!typId) continue;
const vorhanden = await prisma.qualifikation.findFirst({
where: { organisationId, personId: person.id, typId },
select: { id: true },
});
if (vorhanden) continue;
await prisma.qualifikation.create({
data: {
organisationId,
personId: person.id,
typId,
erworbenAm: zuweisung.erworben,
gueltigBis: zuweisung.gueltigBis ?? null,
ausstellendeStelle:
zuweisung.typ.includes("G26") ? "Betriebsarzt Dr. Berger" : "Kreisbrandinspektion Freising",
},
});
angelegt += 1;
}
}
log(`Qualifikationen: ${angelegt}`);
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Ablauf // Ablauf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -574,7 +1104,14 @@ async function main(): Promise<void> {
const rollen = await rollenAnlegen(organisation.id, katalog); const rollen = await rollenAnlegen(organisation.id, katalog);
await adminAnlegen(organisation.id, rollen); await adminAnlegen(organisation.id, rollen);
await personenAnlegen(organisation.id, rollen); await personenAnlegen(organisation.id, rollen);
await fahrzeugeUndGeraeteAnlegen(organisation.id);
// Phase 1 — Stammdaten
const standorte = await standorteAnlegen(organisation.id);
const typen = await geraetetypenAnlegen(organisation.id);
const fahrzeuge = await fahrzeugeAnlegen(organisation.id, standorte);
await geraeteAnlegen(organisation.id, typen, fahrzeuge, standorte);
const qualiTypen = await qualifikationstypenAnlegen(organisation.id);
await qualifikationenAnlegen(organisation.id, qualiTypen);
log("Fertig."); log("Fertig.");
log(""); log("");
+102 -2
View File
@@ -44,8 +44,82 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Übersicht nach der Anmeldung", beschreibung: "Übersicht nach der Anmeldung",
angemeldet: true, angemeldet: true,
}, },
{
name: "03-geraeteliste",
pfad: "/geraete",
beschreibung: "Geräteliste mit Suche, Filter und Spaltenauswahl",
angemeldet: true,
},
{
name: "05-fahrzeuge",
pfad: "/fahrzeuge",
beschreibung: "Fahrzeugübersicht",
angemeldet: true,
},
{
name: "07-mitglieder",
pfad: "/mitglieder",
beschreibung: "Mitgliederliste nach Abteilung",
angemeldet: true,
},
{
name: "08-qualifikationen",
pfad: "/qualifikationen",
beschreibung: "Qualifikationen mit Ablaufwarnung",
angemeldet: true,
},
{
name: "09-import",
pfad: "/geraete/importieren",
beschreibung: "Import-Assistent",
angemeldet: true,
},
]; ];
/**
* Seiten, deren Adresse erst zur Laufzeit feststeht — die Kennung eines
* Geräts oder Fahrzeugs kennt das Skript vorher nicht.
*/
const DYNAMISCHE_AUFNAHMEN: Array<{
name: string;
beschreibung: string;
/** Findet den Link auf der Übersichtsseite. */
von: string;
linkMuster: RegExp;
}> = [
{
name: "04-geraet",
beschreibung: "Gerätedetail mit QR-Code und Verlauf",
von: "/geraete",
linkMuster: /^\/geraete\/[0-9a-f-]{36}$/,
},
{
name: "06-fahrzeug",
beschreibung: "Fahrzeugdetail mit Beladung nach Geräteräumen",
von: "/fahrzeuge",
linkMuster: /^\/fahrzeuge\/[0-9a-f-]{36}$/,
},
];
/**
* Wartet, bis die Seite wirklich fertig ist.
*
* "networkidle" genügt nicht: Listen holen ihre Daten über tRPC nach, und der
* Aufruf startet erst, nachdem React im Browser gelaufen ist. Ohne dieses
* Warten zeigen die Bilder Platzhalterbalken statt echter Daten.
*/
async function fertigGeladen(seite: Page): Promise<void> {
await seite.waitForLoadState("networkidle");
try {
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
timeout: 15_000,
});
} catch {
console.warn("[screenshots] Ladeanzeige blieb stehen — Bild wird trotzdem gemacht.");
}
await seite.waitForTimeout(500);
}
async function anmelden(seite: Page): Promise<void> { async function anmelden(seite: Page): Promise<void> {
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);
@@ -79,8 +153,34 @@ async function main(): Promise<void> {
} }
await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" }); await seite.goto(`${BASIS}${aufnahme.pfad}`, { waitUntil: "networkidle" });
// Kurz warten, damit Übergänge und Symbole fertig gezeichnet sind. await fertigGeladen(seite);
await seite.waitForTimeout(400);
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
await seite.screenshot({ path: datei, fullPage: true });
console.info(`[screenshots] ${aufnahme.beschreibung} (${darstellung}) -> ${datei}`);
}
// Detailseiten: die erste Kennung von der jeweiligen Übersicht holen.
for (const aufnahme of DYNAMISCHE_AUFNAHMEN) {
await seite.goto(`${BASIS}${aufnahme.von}`, { waitUntil: "networkidle" });
await fertigGeladen(seite);
const ziele = await seite
.locator("a[href]")
.evaluateAll((elemente) =>
elemente
.map((element) => element.getAttribute("href") ?? "")
.filter((href) => href.startsWith("/")),
);
const treffer = ziele.find((href) => aufnahme.linkMuster.test(href));
if (!treffer) {
console.warn(`[screenshots] Kein Ziel für ${aufnahme.name} gefunden — übersprungen.`);
continue;
}
await seite.goto(`${BASIS}${treffer}`, { waitUntil: "networkidle" });
await fertigGeladen(seite);
const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`); const datei = path.join(ZIEL, `${aufnahme.name}-${darstellung}.png`);
await seite.screenshot({ path: datei, fullPage: true }); await seite.screenshot({ path: datei, fullPage: true });
+202
View File
@@ -0,0 +1,202 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft, Package } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, StatusAbzeichen } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Fahrzeug" };
/**
* Fahrzeugdetail mit der Beladung, nach Geräteräumen geordnet.
*
* Genau so sucht der Gerätewart: nicht "wo ist die Motorsäge", sondern "was
* ist in G3". Deshalb steht die Beladeordnung im Mittelpunkt und nicht eine
* alphabetische Geräteliste.
*/
export default async function FahrzeugSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
const fahrzeug = await fuerOrganisation(sitzung.organisationId, (db) =>
db.fahrzeug.findUnique({
where: { id },
include: {
standort: { select: { id: true, name: true } },
geraeteraeume: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: {
geraete: {
where: { geloeschtAm: null },
orderBy: { bezeichnung: "asc" },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
naechstePruefung: true,
},
},
},
},
geraete: {
where: { geloeschtAm: null, geraeteraumId: null },
orderBy: { bezeichnung: "asc" },
select: {
id: true,
inventarnummer: true,
bezeichnung: true,
status: true,
naechstePruefung: true,
},
},
},
}),
);
if (!fahrzeug) notFound();
const angaben: Array<[string, React.ReactNode]> = [
["Typ nach DIN", fahrzeug.dinTyp ?? "—"],
["Kennzeichen", <span className="tabellenzahlen">{fahrzeug.kennzeichen ?? "—"}</span>],
["Hersteller", fahrzeug.hersteller ?? "—"],
["Aufbauhersteller", fahrzeug.aufbauhersteller ?? "—"],
[
"Fahrgestellnummer",
<span className="tabellenzahlen">{fahrzeug.fahrgestellnummer ?? "—"}</span>,
],
["Baujahr", <span className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</span>],
["Erstzulassung", alsDatum(fahrzeug.erstzulassung)],
["Indienststellung", alsDatum(fahrzeug.indienststellung)],
["Nächster TÜV", <FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />],
["Sicherheitsprüfung (UVV)", <FristAnzeige datum={fahrzeug.uvvBis} vorwarnTage={60} />],
["Sitzplätze", <span className="tabellenzahlen">{fahrzeug.sitzplaetze ?? "—"}</span>],
[
"Löschwasser",
fahrzeug.tankinhalt ? (
<span className="tabellenzahlen">{fahrzeug.tankinhalt.toLocaleString("de-DE")} l</span>
) : (
"—"
),
],
["Standort", fahrzeug.standort?.name ?? "—"],
];
const alleRaeume = [
...fahrzeug.geraeteraeume,
// Was keinem Raum zugeordnet ist, bekommt einen eigenen Abschnitt statt
// stillschweigend zu verschwinden.
...(fahrzeug.geraete.length > 0
? [
{
id: "__ohne__",
bezeichnung: "Ohne Raumangabe",
beschreibung: "Verlastet, aber keinem Geräteraum zugeordnet",
geraete: fahrzeug.geraete,
},
]
: []),
];
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/fahrzeuge">
<ArrowLeft aria-hidden />
Zurück zu den Fahrzeugen
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{fahrzeug.funkrufname}</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
{fahrzeug.bezeichnung}
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2 lg:grid-cols-3">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Beladung</CardTitle>
<CardDescription>
Nach Geräteräumen geordnet, in der Reihenfolge der Beladeordnung.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
{alleRaeume.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Für dieses Fahrzeug sind noch keine Geräteräume angelegt.
</p>
)}
{alleRaeume.map((raum) => (
<div key={raum.id}>
<div className="mb-2 flex items-baseline gap-2">
<Badge variant="secondary">{raum.bezeichnung}</Badge>
<span className="text-xs text-[var(--muted-foreground)]">
{raum.beschreibung}
</span>
<span className="ml-auto text-xs text-[var(--muted-foreground)]">
{raum.geraete.length}{" "}
{raum.geraete.length === 1 ? "Gerät" : "Geräte"}
</span>
</div>
{raum.geraete.length === 0 ? (
<p className="pl-1 text-sm text-[var(--muted-foreground)]">leer</p>
) : (
<ul className="divide-y divide-[var(--border)] rounded-md border border-[var(--border)]">
{raum.geraete.map((geraet) => (
<li
key={geraet.id}
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
>
<Package className="size-4 text-[var(--muted-foreground)]" aria-hidden />
<Link
href={`/geraete/${geraet.id}`}
className="font-medium underline-offset-4 hover:underline"
>
{geraet.bezeichnung}
</Link>
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{geraet.inventarnummer}
</span>
<span className="ml-auto flex items-center gap-3">
<FristAnzeige datum={geraet.naechstePruefung} />
<StatusAbzeichen status={geraet.status} />
</span>
</li>
))}
</ul>
)}
</div>
))}
</CardContent>
</Card>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { Truck } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Fahrzeuge" };
const ART_TEXT: Record<string, string> = {
LOESCHFAHRZEUG: "Löschfahrzeug",
DREHLEITER: "Drehleiter",
RUESTWAGEN: "Rüstwagen",
GERAETEWAGEN: "Gerätewagen",
MANNSCHAFTSTRANSPORT: "Mannschaftstransport",
EINSATZLEITWAGEN: "Einsatzleitwagen",
WECHSELLADER: "Wechsellader",
ANHAENGER: "Anhänger",
SONSTIGES: "Sonstiges",
};
/**
* Fahrzeugübersicht als Kacheln statt als Tabelle.
*
* Eine Wehr hat drei bis zehn Fahrzeuge, nicht dreihundert. Für diese Menge
* ist eine Kachel mit den wichtigen Terminen brauchbarer als eine Zeile: der
* TÜV-Termin und die Zahl der verlasteten Geräte sind das, wonach man sucht.
*/
export default async function FahrzeugeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const fahrzeuge = await fuerOrganisation(sitzung.organisationId, (db) =>
db.fahrzeug.findMany({
where: { geloeschtAm: null },
orderBy: { funkrufname: "asc" },
include: {
standort: { select: { name: true } },
_count: { select: { geraete: true, geraeteraeume: true } },
},
}),
);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Fahrzeuge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Einsatzfahrzeuge der Wehr mit Terminen und verlasteten Geräten.
</p>
</div>
{fahrzeuge.length === 0 ? (
<NichtsGefunden
titel="Noch keine Fahrzeuge erfasst"
hinweis="Fahrzeuge lassen sich einzeln anlegen oder zusammen mit den Geräten importieren."
/>
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{fahrzeuge.map((fahrzeug) => (
<Card key={fahrzeug.id} className={!fahrzeug.aktiv ? "opacity-60" : undefined}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="truncate">
<Link
href={`/fahrzeuge/${fahrzeug.id}`}
className="underline-offset-4 hover:underline"
>
{fahrzeug.funkrufname}
</Link>
</CardTitle>
<CardDescription className="truncate">
{fahrzeug.dinTyp ? `${fahrzeug.dinTyp} — ` : ""}
{fahrzeug.bezeichnung}
</CardDescription>
</div>
<Truck className="size-5 shrink-0 text-[var(--muted-foreground)]" aria-hidden />
</div>
</CardHeader>
<CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">{ART_TEXT[fahrzeug.art] ?? fahrzeug.art}</Badge>
{fahrzeug.kennzeichen && (
<Badge variant="outline" className="tabellenzahlen">
{fahrzeug.kennzeichen}
</Badge>
)}
{!fahrzeug.aktiv && <Badge variant="ausgesondert">außer Dienst</Badge>}
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Nächster TÜV
</dt>
<dd>
<FristAnzeige datum={fahrzeug.tuevBis} vorwarnTage={60} />
</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Baujahr
</dt>
<dd className="tabellenzahlen">{fahrzeug.baujahr ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Standort
</dt>
<dd className="truncate">{fahrzeug.standort?.name ?? "—"}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Indienststellung
</dt>
<dd className="tabellenzahlen">{alsDatum(fahrzeug.indienststellung)}</dd>
</div>
</dl>
<p className="border-t border-[var(--border)] pt-2 text-[var(--muted-foreground)]">
<Link
href={`/geraete?fahrzeug=${fahrzeug.id}`}
className="underline-offset-4 hover:underline"
>
{fahrzeug._count.geraete}{" "}
{fahrzeug._count.geraete === 1 ? "Gerät" : "Geräte"} verlastet
</Link>
{" · "}
{fahrzeug._count.geraeteraeume}{" "}
{fahrzeug._count.geraeteraeume === 1 ? "Geräteraum" : "Geräteräume"}
</p>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { notFound, redirect } from "next/navigation";
import Link from "next/link";
import Image from "next/image";
import { ArrowLeft, Printer } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { qrAlsDataUri, qrZiel } from "@/lib/stammdaten/qr";
import { alsDatum, alsDatumZeit, alsEuro } from "@/lib/utils";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { StatusAbzeichen, FristAnzeige } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Gerät" };
/**
* Detailseite eines Geräts.
*
* Serverseitig gerendert: hier wird nichts gefiltert und nichts sortiert, also
* braucht es auch keinen Zustand im Browser. Der QR-Code wird auf dem Server
* erzeugt und als Data-URI eingebettet — so lädt die Seite nichts nach.
*/
export default async function GeraetSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const geraet = await db.geraet.findUnique({
where: { id },
include: {
geraetetyp: true,
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
});
if (!geraet) return null;
const verlauf = await db.auditLog.findMany({
where: { entitaet: "Geraet", entitaetId: id },
orderBy: { zeitpunkt: "desc" },
take: 30,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
akteurName: true,
},
});
return { geraet, verlauf };
});
if (!daten) notFound();
const { geraet, verlauf } = daten;
const qr = await qrAlsDataUri(geraet.inventarnummer, 320);
const angaben: Array<[string, React.ReactNode]> = [
["Inventarnummer", <span className="tabellenzahlen">{geraet.inventarnummer}</span>],
["Gerätetyp", geraet.geraetetyp?.name ?? "—"],
["Hersteller", geraet.hersteller ?? "—"],
["Modell", geraet.modell ?? "—"],
["Seriennummer", <span className="tabellenzahlen">{geraet.seriennummer ?? "—"}</span>],
["Herstellungsdatum", alsDatum(geraet.herstellungsdatum)],
["Indienststellung", alsDatum(geraet.indienststellung)],
[
"Anschaffungspreis",
geraet.anschaffungspreis ? alsEuro(Number(geraet.anschaffungspreis)) : "—",
],
[
"Prüfintervall",
geraet.pruefintervallMonate
? `alle ${geraet.pruefintervallMonate} Monate`
: (geraet.geraetetyp?.pruefintervallMonate
? `alle ${geraet.geraetetyp.pruefintervallMonate} Monate (aus dem Gerätetyp)`
: "keines hinterlegt"),
],
["Letzte Prüfung", alsDatum(geraet.letztePruefung)],
["Nächste Prüfung", <FristAnzeige datum={geraet.naechstePruefung} />],
["Lebensdauer bis", <FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />],
["Prüfvorschrift", geraet.geraetetyp?.pruefvorschrift ?? "—"],
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{geraet.bezeichnung}</h1>
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<span className="tabellenzahlen">{geraet.inventarnummer}</span>
<StatusAbzeichen status={geraet.status} />
</p>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
<div className="mt-6 border-t border-[var(--border)] pt-4">
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Lagerung
</p>
<p className="text-sm">
{geraet.fahrzeug ? (
<>
Verlastet auf{" "}
<Link
href={`/fahrzeuge/${geraet.fahrzeug.id}`}
className="underline underline-offset-4"
>
{geraet.fahrzeug.funkrufname}
</Link>
{geraet.geraeteraum && `, ${geraet.geraeteraum.bezeichnung}`}
</>
) : (
`Standort ${geraet.standort?.name ?? "unbekannt"}`
)}
{geraet.lagerort && ` — ${geraet.lagerort}`}
</p>
</div>
{geraet.bemerkung && (
<div className="mt-4 border-t border-[var(--border)] pt-4">
<p className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
Bemerkung
</p>
<p className="whitespace-pre-line text-sm">{geraet.bemerkung}</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>QR-Code</CardTitle>
<CardDescription>
Aufkleber im Geräteraum. Wer ihn scannt, landet direkt auf dieser Seite.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* Data-URI: die Grafik kommt aus dem Server-Rendering, nicht aus
einem Nachladen. */}
<Image
src={qr}
alt={`QR-Code für ${geraet.inventarnummer}`}
width={320}
height={320}
unoptimized
className="mx-auto h-auto w-full max-w-56 rounded border border-[var(--border)] bg-white p-2"
/>
<p className="break-all text-center text-xs text-[var(--muted-foreground)]">
{qrZiel(geraet.inventarnummer)}
</p>
<form action="/api/geraete/etiketten" method="post">
<input type="hidden" name="id" value={geraet.id} />
<Button type="submit" variant="outline" className="w-full">
<Printer aria-hidden />
Etikett drucken
</Button>
</form>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Verlauf</CardTitle>
<CardDescription>
Gerendert aus dem Audit-Log. Es gibt bewusst keine zweite Historie —
zwei Quellen könnten auseinanderlaufen.
</CardDescription>
</CardHeader>
<CardContent>
{verlauf.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Zu diesem Gerät ist noch nichts protokolliert.
</p>
) : (
<ol className="space-y-3">
{verlauf.map((eintrag) => (
<li
key={eintrag.id}
className="flex flex-wrap items-baseline gap-x-3 gap-y-1 border-l-2 border-[var(--border)] pl-3 text-sm"
>
<span className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{alsDatumZeit(eintrag.zeitpunkt)}
</span>
<span>{eintrag.beschreibung ?? eintrag.aktion}</span>
{eintrag.akteurName && (
<span className="text-xs text-[var(--muted-foreground)]">
— {eintrag.akteurName}
</span>
)}
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
);
}
+572
View File
@@ -0,0 +1,572 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { GeraetStatus } from "@prisma/client";
import {
ArrowDown,
ArrowUp,
ChevronLeft,
ChevronRight,
Columns3,
Printer,
QrCode,
Search,
Upload,
X,
} from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
FristAnzeige,
Fehlermeldung,
GERAET_STATUS_TEXT,
LadeZeilen,
NichtsGefunden,
StatusAbzeichen,
} from "@/components/stammdaten/anzeige";
import { alsDatum, cn } from "@/lib/utils";
/**
* Geräteliste.
*
* Sortierung, Filter und Blättern laufen serverseitig — bei einer Wehr mit
* mehreren hundert Geräten will man nicht alles in den Browser laden, nur um
* dort zu sortieren. Die Suche wartet bewusst 300 ms, sonst geht bei jedem
* Tastendruck eine Abfrage raus.
*
* Die Spaltenauswahl bleibt im Browser gespeichert: der Gerätewart schaut auf
* Prüftermine, der Kassier auf Anschaffungspreise, und beide wollen ihre
* Einstellung beim nächsten Mal wiederfinden.
*/
interface Spalte {
schluessel: string;
titel: string;
sortierbar?: boolean;
/** Standardmäßig eingeblendet. */
vorgabe: boolean;
/** Rechtsbündig für Zahlen und Daten. */
zahl?: boolean;
}
const SPALTEN: Spalte[] = [
{ schluessel: "inventarnummer", titel: "Inventarnr.", sortierbar: true, vorgabe: true },
{ schluessel: "bezeichnung", titel: "Bezeichnung", sortierbar: true, vorgabe: true },
{ schluessel: "geraetetyp", titel: "Gerätetyp", vorgabe: false },
{ schluessel: "hersteller", titel: "Hersteller", sortierbar: true, vorgabe: true },
{ schluessel: "modell", titel: "Modell", vorgabe: false },
{ schluessel: "seriennummer", titel: "Seriennummer", vorgabe: false },
{ schluessel: "lagerung", titel: "Lagerung", vorgabe: true },
{ schluessel: "status", titel: "Status", sortierbar: true, vorgabe: true },
{
schluessel: "naechstePruefung",
titel: "Nächste Prüfung",
sortierbar: true,
vorgabe: true,
zahl: true,
},
{ schluessel: "letztePruefung", titel: "Letzte Prüfung", vorgabe: false, zahl: true },
{
schluessel: "lebensdauerBis",
titel: "Lebensdauer bis",
sortierbar: true,
vorgabe: false,
zahl: true,
},
{
schluessel: "indienststellung",
titel: "Indienststellung",
sortierbar: true,
vorgabe: false,
zahl: true,
},
];
const SPEICHERSCHLUESSEL = "florian.geraete.spalten";
export function GeraeteTabelle({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const [suchtext, setSuchtext] = React.useState("");
const [suche, setSuche] = React.useState("");
const [seite, setSeite] = React.useState(1);
const [sortierung, setSortierung] = React.useState<string>("inventarnummer");
const [richtung, setRichtung] = React.useState<"asc" | "desc">("asc");
const [statusFilter, setStatusFilter] = React.useState<GeraetStatus | "alle">("alle");
const [ausgewaehlt, setAusgewaehlt] = React.useState<Set<string>>(new Set());
const [spaltenOffen, setSpaltenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [sichtbar, setSichtbar] = React.useState<Set<string>>(
() => new Set(SPALTEN.filter((spalte) => spalte.vorgabe).map((spalte) => spalte.schluessel)),
);
// Gespeicherte Spaltenauswahl nachladen — erst nach der Hydratation, sonst
// unterscheidet sich der Serverbaum vom Browserbaum.
React.useEffect(() => {
const gespeichert = window.localStorage.getItem(SPEICHERSCHLUESSEL);
if (!gespeichert) return;
try {
const liste = JSON.parse(gespeichert) as string[];
if (Array.isArray(liste) && liste.length > 0) setSichtbar(new Set(liste));
} catch {
// Kaputter Eintrag: Vorgabe behalten.
}
}, []);
React.useEffect(() => {
window.localStorage.setItem(SPEICHERSCHLUESSEL, JSON.stringify([...sichtbar]));
}, [sichtbar]);
// Entprellte Suche.
React.useEffect(() => {
const zeitgeber = setTimeout(() => {
setSuche(suchtext);
setSeite(1);
}, 300);
return () => clearTimeout(zeitgeber);
}, [suchtext]);
const abfrage = trpc.geraet.liste.useQuery({
suche: suche || undefined,
seite,
seitengroesse: 50,
sortierung: sortierung as never,
richtung,
status: statusFilter === "alle" ? undefined : [statusFilter],
});
const werkzeuge = trpc.useUtils();
const statusSetzen = trpc.geraet.statusSetzen.useMutation({
onSuccess: (ergebnis) => {
setMeldung(`${ergebnis.geaendert} Geräte geändert.`);
setAusgewaehlt(new Set());
void werkzeuge.geraet.liste.invalidate();
void werkzeuge.organisation.kennzahlen.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = abfrage.data?.eintraege ?? [];
const alleAusgewaehlt = eintraege.length > 0 && eintraege.every((g) => ausgewaehlt.has(g.id));
function sortiereNach(spalte: string) {
if (sortierung === spalte) {
setRichtung((alt) => (alt === "asc" ? "desc" : "asc"));
} else {
setSortierung(spalte);
setRichtung("asc");
}
setSeite(1);
}
function auswahlUmschalten(id: string) {
setAusgewaehlt((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
return neu;
});
}
function alleUmschalten() {
setAusgewaehlt((alt) => {
if (alleAusgewaehlt) {
const neu = new Set(alt);
for (const geraet of eintraege) neu.delete(geraet.id);
return neu;
}
return new Set([...alt, ...eintraege.map((geraet) => geraet.id)]);
});
}
/** Etikettenbogen für die Auswahl erzeugen und herunterladen. */
async function etikettenDrucken() {
setMeldung(null);
try {
const antwort = await fetch("/api/geraete/etiketten", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: [...ausgewaehlt] }),
});
if (!antwort.ok) {
const text = await antwort.text();
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${text}`);
return;
}
const datei = await antwort.blob();
const adresse = URL.createObjectURL(datei);
window.open(adresse, "_blank", "noopener");
// Nach dem Öffnen wieder freigeben, sonst bleibt der Speicher belegt.
setTimeout(() => URL.revokeObjectURL(adresse), 60_000);
} catch (fehler) {
setMeldung(`Der Etikettenbogen ließ sich nicht erzeugen: ${String(fehler)}`);
}
}
const zeigeSpalte = (schluessel: string) => sichtbar.has(schluessel);
const spaltenAnzahl = [...sichtbar].length + 1;
return (
<div className="space-y-4">
{/* Werkzeugleiste */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suchtext}
onChange={(ereignis) => setSuchtext(ereignis.target.value)}
placeholder="Inventarnummer, Bezeichnung, Hersteller, Seriennummer …"
className="pl-8"
aria-label="Geräte durchsuchen"
/>
{suchtext && (
<button
type="button"
onClick={() => setSuchtext("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
aria-label="Suche zurücksetzen"
>
<X className="size-4" aria-hidden />
</button>
)}
</div>
<Select
value={statusFilter}
onValueChange={(wert) => {
setStatusFilter(wert as GeraetStatus | "alle");
setSeite(1);
}}
>
<SelectTrigger className="w-48" aria-label="Nach Status filtern">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Zustände</SelectItem>
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<Button
variant="outline"
onClick={() => setSpaltenOffen((offen) => !offen)}
aria-expanded={spaltenOffen}
>
<Columns3 aria-hidden />
Spalten
</Button>
{spaltenOffen && (
<>
<button
type="button"
className="fixed inset-0 z-40 cursor-default"
aria-label="Spaltenauswahl schließen"
onClick={() => setSpaltenOffen(false)}
/>
<div className="absolute right-0 z-50 mt-1 w-64 rounded-md border border-[var(--border)] bg-[var(--popover)] p-2 shadow-md">
{SPALTEN.map((spalte) => (
<label
key={spalte.schluessel}
className="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-[var(--accent)]"
>
<Checkbox
checked={sichtbar.has(spalte.schluessel)}
onCheckedChange={(an) =>
setSichtbar((alt) => {
const neu = new Set(alt);
if (an) neu.add(spalte.schluessel);
else neu.delete(spalte.schluessel);
// Mindestens eine Spalte muss stehen bleiben.
return neu.size === 0 ? alt : neu;
})
}
/>
{spalte.titel}
</label>
))}
</div>
</>
)}
</div>
<Button variant="outline" asChild>
<Link href="/geraete/scannen">
<QrCode aria-hidden />
Scannen
</Link>
</Button>
{darfBearbeiten && (
<Button variant="outline" asChild>
<Link href="/geraete/importieren">
<Upload aria-hidden />
Importieren
</Link>
</Button>
)}
</div>
{/* Sammelaktionen — erscheinen erst, wenn etwas ausgewählt ist. */}
{ausgewaehlt.size > 0 && (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-[var(--border)] bg-[var(--muted)] px-3 py-2">
<span className="text-sm font-medium">
{ausgewaehlt.size} {ausgewaehlt.size === 1 ? "Gerät" : "Geräte"} ausgewählt
</span>
<div className="ml-auto flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={etikettenDrucken}>
<Printer aria-hidden />
Etiketten drucken
</Button>
{darfBearbeiten && (
<Select
onValueChange={(wert) =>
statusSetzen.mutate({
ids: [...ausgewaehlt],
status: wert as GeraetStatus,
})
}
>
<SelectTrigger className="h-8 w-52" aria-label="Status aller ausgewählten setzen">
<SelectValue placeholder="Status setzen auf …" />
</SelectTrigger>
<SelectContent>
{Object.entries(GERAET_STATUS_TEXT).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
)}
<Button variant="ghost" size="sm" onClick={() => setAusgewaehlt(new Set())}>
Auswahl aufheben
</Button>
</div>
</div>
)}
{meldung && <Fehlermeldung meldung={meldung} />}
{abfrage.error && <Fehlermeldung meldung={abfrage.error.message} />}
{/* Tabelle */}
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={alleAusgewaehlt}
onCheckedChange={alleUmschalten}
aria-label="Alle auf dieser Seite auswählen"
/>
</TableHead>
{SPALTEN.filter((spalte) => zeigeSpalte(spalte.schluessel)).map((spalte) => (
<TableHead key={spalte.schluessel} className={cn(spalte.zahl && "text-right")}>
{spalte.sortierbar ? (
<button
type="button"
onClick={() => sortiereNach(spalte.schluessel)}
className="inline-flex items-center gap-1 uppercase hover:text-[var(--foreground)]"
aria-label={`Nach ${spalte.titel} sortieren`}
>
{spalte.titel}
{sortierung === spalte.schluessel &&
(richtung === "asc" ? (
<ArrowUp className="size-3" aria-hidden />
) : (
<ArrowDown className="size-3" aria-hidden />
))}
</button>
) : (
spalte.titel
)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{abfrage.isLoading && <LadeZeilen spalten={spaltenAnzahl} />}
{!abfrage.isLoading &&
eintraege.map((geraet) => (
<TableRow key={geraet.id} data-ausgewaehlt={ausgewaehlt.has(geraet.id)}>
<TableCell>
<Checkbox
checked={ausgewaehlt.has(geraet.id)}
onCheckedChange={() => auswahlUmschalten(geraet.id)}
aria-label={`${geraet.inventarnummer} auswählen`}
/>
</TableCell>
{zeigeSpalte("inventarnummer") && (
<TableCell className="font-medium tabellenzahlen">
<Link
href={`/geraete/${geraet.id}`}
className="underline-offset-4 hover:underline"
>
{geraet.inventarnummer}
</Link>
</TableCell>
)}
{zeigeSpalte("bezeichnung") && <TableCell>{geraet.bezeichnung}</TableCell>}
{zeigeSpalte("geraetetyp") && (
<TableCell className="text-[var(--muted-foreground)]">
{geraet.geraetetyp?.name ?? "—"}
</TableCell>
)}
{zeigeSpalte("hersteller") && <TableCell>{geraet.hersteller ?? "—"}</TableCell>}
{zeigeSpalte("modell") && <TableCell>{geraet.modell ?? "—"}</TableCell>}
{zeigeSpalte("seriennummer") && (
<TableCell className="tabellenzahlen">{geraet.seriennummer ?? "—"}</TableCell>
)}
{zeigeSpalte("lagerung") && (
<TableCell className="whitespace-nowrap text-sm">
{geraet.fahrzeug ? (
<>
{geraet.fahrzeug.funkrufname}
{geraet.geraeteraum && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {geraet.geraeteraum.bezeichnung}
</span>
)}
</>
) : (
(geraet.standort?.name ?? "—")
)}
</TableCell>
)}
{zeigeSpalte("status") && (
<TableCell>
<StatusAbzeichen status={geraet.status} />
</TableCell>
)}
{zeigeSpalte("naechstePruefung") && (
<TableCell className="text-right">
<FristAnzeige datum={geraet.naechstePruefung} />
</TableCell>
)}
{zeigeSpalte("letztePruefung") && (
<TableCell className="text-right tabellenzahlen">
{alsDatum(geraet.letztePruefung)}
</TableCell>
)}
{zeigeSpalte("lebensdauerBis") && (
<TableCell className="text-right">
<FristAnzeige datum={geraet.lebensdauerBis} vorwarnTage={90} />
</TableCell>
)}
{zeigeSpalte("indienststellung") && (
<TableCell className="text-right tabellenzahlen">
{alsDatum(geraet.indienststellung)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
{!abfrage.isLoading && eintraege.length === 0 && (
<div className="p-6">
<NichtsGefunden
titel={suche ? "Kein Gerät gefunden" : "Noch keine Geräte erfasst"}
hinweis={
suche
? `Zu "${suche}" gibt es keinen Treffer. Vielleicht hilft ein Teil der Inventarnummer.`
: "Geräte lassen sich einzeln anlegen oder aus einer vorhandenen Tabelle importieren."
}
aktion={
!suche && darfBearbeiten ? (
<Button asChild>
<Link href="/geraete/importieren">
<Upload aria-hidden />
Aus Datei importieren
</Link>
</Button>
) : undefined
}
/>
</div>
)}
</div>
{/* Blättern */}
{abfrage.data && abfrage.data.gesamt > 0 && (
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<p className="text-[var(--muted-foreground)]">
{abfrage.data.gesamt} {abfrage.data.gesamt === 1 ? "Gerät" : "Geräte"}
{abfrage.data.seitenGesamt > 1 &&
` · Seite ${abfrage.data.seite} von ${abfrage.data.seitenGesamt}`}
</p>
{abfrage.data.seitenGesamt > 1 && (
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
disabled={seite <= 1}
onClick={() => setSeite((alt) => Math.max(1, alt - 1))}
>
<ChevronLeft aria-hidden />
Zurück
</Button>
<Button
variant="outline"
size="sm"
disabled={seite >= abfrage.data.seitenGesamt}
onClick={() => setSeite((alt) => alt + 1)}
>
Weiter
<ChevronRight aria-hidden />
</Button>
</div>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,421 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { AlertTriangle, CheckCircle2, FileSpreadsheet, Upload } from "lucide-react";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { cn } from "@/lib/utils";
/**
* Import-Assistent.
*
* Drei Schritte, und der mittlere ist der eigentliche Punkt der Übung:
*
* 1. Datei wählen
* 2. Zuordnung prüfen und Vorschau ansehen
* 3. Übernehmen
*
* Zwischen 2 und 3 sitzt ein Mensch. Eine falsch zugeordnete Spalte verteilt
* sonst Seriennummern auf Inventarnummern, und das merkt man erst Wochen
* später am Regal.
*
* Die Datei wird beim Übernehmen erneut zum Server geschickt und dort neu
* eingelesen. Das kostet eine Übertragung, verhindert aber, dass jemand die
* Prüfung umgeht und eigene Werte einschleust.
*/
type ImportArt = "geraet" | "person" | "fahrzeug";
const ART_BEZEICHNUNG: Record<ImportArt, string> = {
geraet: "Geräte",
person: "Mitglieder",
fahrzeug: "Fahrzeuge",
};
export function ImportAssistent() {
const router = useRouter();
const werkzeuge = trpc.useUtils();
const [art, setArt] = React.useState<ImportArt>("geraet");
const [datei, setDatei] = React.useState<{ name: string; inhalt: string } | null>(null);
const [zuordnung, setZuordnung] = React.useState<Record<string, string | null>>({});
const [aktualisieren, setAktualisieren] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [ergebnis, setErgebnis] = React.useState<{
angelegt: number;
aktualisiert: number;
uebersprungen: number;
nichtUebernommen: number;
fehler: string[];
} | null>(null);
const vorschau = trpc.datenImport.vorschau.useMutation({
onSuccess: (daten) => {
setZuordnung(daten.zuordnung);
setFehler(null);
},
onError: (ausnahme) => setFehler(ausnahme.message),
});
const uebernehmen = trpc.datenImport.uebernehmen.useMutation({
onSuccess: (daten) => {
setErgebnis(daten);
setFehler(null);
void werkzeuge.geraet.liste.invalidate();
void werkzeuge.organisation.kennzahlen.invalidate();
},
onError: (ausnahme) => setFehler(ausnahme.message),
});
async function dateiWaehlen(ereignis: React.ChangeEvent<HTMLInputElement>) {
const gewaehlt = ereignis.target.files?.[0];
if (!gewaehlt) return;
setErgebnis(null);
setFehler(null);
const puffer = await gewaehlt.arrayBuffer();
// Blockweise umwandeln: bei großen Dateien sprengt ein einziger
// String.fromCharCode-Aufruf den Aufrufstapel.
const bytes = new Uint8Array(puffer);
let roh = "";
const block = 8192;
for (let i = 0; i < bytes.length; i += block) {
roh += String.fromCharCode(...bytes.subarray(i, i + block));
}
const inhalt = btoa(roh);
setDatei({ name: gewaehlt.name, inhalt });
vorschau.mutate({ dateiname: gewaehlt.name, inhalt, art });
}
function zuordnungAendern(spalte: string, feld: string) {
const neu = { ...zuordnung, [spalte]: feld === "__keine__" ? null : feld };
setZuordnung(neu);
if (datei) {
vorschau.mutate({ dateiname: datei.name, inhalt: datei.inhalt, art, zuordnung: neu });
}
}
const daten = vorschau.data;
// Schritt 3 geschafft.
if (ergebnis) {
return (
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<CheckCircle2 className="size-6 text-[var(--einsatzbereit)]" aria-hidden />
<div>
<CardTitle>Import abgeschlossen</CardTitle>
<CardDescription>Aus der Datei „{datei?.name}“.</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<dl className="grid gap-4 sm:grid-cols-4">
{[
["Neu angelegt", ergebnis.angelegt],
["Aktualisiert", ergebnis.aktualisiert],
["Übersprungen", ergebnis.uebersprungen],
["Nicht übernommen", ergebnis.nichtUebernommen],
].map(([bezeichnung, wert]) => (
<div key={bezeichnung as string}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-2xl font-semibold tabellenzahlen">{wert}</dd>
</div>
))}
</dl>
{ergebnis.uebersprungen > 0 && !aktualisieren && (
<p className="text-sm text-[var(--muted-foreground)]">
Übersprungen wurden Datensätze, die es schon gibt. Mit „Vorhandene
aktualisieren“ werden sie stattdessen überschrieben.
</p>
)}
{ergebnis.fehler.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="mb-1 text-sm font-medium">
{ergebnis.fehler.length} Zeilen ließen sich nicht schreiben:
</p>
<ul className="list-disc space-y-0.5 pl-5 text-xs">
{ergebnis.fehler.slice(0, 20).map((meldung) => (
<li key={meldung}>{meldung}</li>
))}
</ul>
</div>
)}
<div className="flex gap-2">
<Button onClick={() => router.push("/geraete")}>Zur Geräteliste</Button>
<Button
variant="outline"
onClick={() => {
setErgebnis(null);
setDatei(null);
vorschau.reset();
}}
>
Weitere Datei importieren
</Button>
</div>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{fehler && <Fehlermeldung meldung={fehler} />}
{/* Schritt 1 */}
<Card>
<CardHeader>
<CardTitle>1. Datei wählen</CardTitle>
<CardDescription>
CSV oder Excel (.xlsx). Die erste Zeile muss die Spaltenüberschriften
enthalten. Höchstens 10 MB und 5.000 Zeilen.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="art">Was wird importiert?</Label>
<Select
value={art}
onValueChange={(wert) => {
setArt(wert as ImportArt);
setDatei(null);
vorschau.reset();
}}
>
<SelectTrigger id="art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(ART_BEZEICHNUNG).map(([wert, text]) => (
<SelectItem key={wert} value={wert}>
{text}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="datei">Datei</Label>
<input
id="datei"
type="file"
accept=".csv,.txt,.xlsx,.xlsm"
onChange={dateiWaehlen}
className="block w-full cursor-pointer rounded-md border border-[var(--input)] bg-[var(--background)] text-sm file:mr-3 file:cursor-pointer file:rounded-l-md file:border-0 file:bg-[var(--secondary)] file:px-3 file:py-2 file:text-sm file:font-medium"
/>
</div>
</div>
{datei && (
<p className="flex items-center gap-2 text-sm text-[var(--muted-foreground)]">
<FileSpreadsheet className="size-4" aria-hidden />
{datei.name}
</p>
)}
</CardContent>
</Card>
{vorschau.isPending && (
<p className="text-sm text-[var(--muted-foreground)]">Die Datei wird gelesen …</p>
)}
{/* Schritt 2 */}
{daten && (
<>
<Card>
<CardHeader>
<CardTitle>2. Spalten zuordnen</CardTitle>
<CardDescription>
Die Zuordnung wurde nach den Überschriften geraten. Bitte prüfen und
nötigenfalls korrigieren — danach passt sich die Vorschau sofort an.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{daten.fehlendePflichtfelder.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<AlertTriangle
className="mt-0.5 size-4 shrink-0 text-[var(--destructive)]"
aria-hidden
/>
<span>
Diesen Pflichtfeldern ist keine Spalte zugeordnet:{" "}
<strong>{daten.fehlendePflichtfelder.join(", ")}</strong>. Ohne sie lässt
sich nichts übernehmen.
</span>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{daten.spalten.map((spalte) => (
<div key={spalte} className="space-y-1">
<Label className="text-xs text-[var(--muted-foreground)]">
Spalte „{spalte}“
</Label>
<Select
value={zuordnung[spalte] ?? "__keine__"}
onValueChange={(wert) => zuordnungAendern(spalte, wert)}
>
<SelectTrigger aria-label={`Zielfeld für Spalte ${spalte}`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__keine__">— nicht übernehmen —</SelectItem>
{daten.felder.map((feld) => (
<SelectItem key={feld.schluessel} value={feld.schluessel}>
{feld.bezeichnung}
{feld.pflicht ? " *" : ""}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Vorschau</CardTitle>
<CardDescription>
{daten.anzahlUebernehmbar} von {daten.zeilenGesamt} Zeilen sind
übernehmbar
{daten.anzahlFehlerhaft > 0 && `, ${daten.anzahlFehlerhaft} haben Fehler`}.
{daten.gekuerzt && " Die Datei wurde auf 5.000 Zeilen gekürzt."}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-h-96 overflow-y-auto rounded-md border border-[var(--border)]">
<Table>
<TableHeader className="sticky top-0 bg-[var(--card)]">
<TableRow>
<TableHead className="w-12">Zeile</TableHead>
{daten.felder
.filter((feld) =>
Object.values(zuordnung).includes(feld.schluessel),
)
.map((feld) => (
<TableHead key={feld.schluessel}>{feld.bezeichnung}</TableHead>
))}
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daten.zeilen.map((zeile) => (
<TableRow
key={zeile.nummer}
className={cn(!zeile.uebernehmbar && "bg-[var(--destructive)]/5")}
>
<TableCell className="tabellenzahlen text-xs text-[var(--muted-foreground)]">
{zeile.nummer}
</TableCell>
{daten.felder
.filter((feld) =>
Object.values(zuordnung).includes(feld.schluessel),
)
.map((feld) => {
const wert = zeile.werte[feld.schluessel];
return (
<TableCell key={feld.schluessel} className="max-w-48 truncate text-sm">
{wert instanceof Date
? wert.toLocaleDateString("de-DE")
: wert === undefined || wert === null
? "—"
: String(wert)}
</TableCell>
);
})}
<TableCell className="text-xs">
{zeile.fehler.length === 0 ? (
<span className="text-[var(--einsatzbereit)]">in Ordnung</span>
) : (
<span className="text-[var(--destructive)]">
{zeile.fehler.map((f) => f.meldung).join("; ")}
</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={aktualisieren}
onCheckedChange={(an) => setAktualisieren(Boolean(an))}
/>
Vorhandene Datensätze aktualisieren statt überspringen
</label>
<div className="flex flex-wrap items-center gap-3">
<Button
disabled={
!datei ||
daten.anzahlUebernehmbar === 0 ||
daten.fehlendePflichtfelder.length > 0 ||
uebernehmen.isPending
}
onClick={() =>
datei &&
uebernehmen.mutate({
dateiname: datei.name,
inhalt: datei.inhalt,
art,
zuordnung,
vorhandeneAktualisieren: aktualisieren,
})
}
>
<Upload aria-hidden />
{uebernehmen.isPending
? "Wird übernommen …"
: `${daten.anzahlUebernehmbar} Zeilen übernehmen`}
</Button>
{daten.anzahlFehlerhaft > 0 && (
<p className="text-sm text-[var(--muted-foreground)]">
Zeilen mit Fehlern werden übersprungen und bleiben in der Datei.
</p>
)}
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}
@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Button } from "@/components/ui/button";
import { ImportAssistent } from "@/app/(app)/geraete/importieren/import-assistent";
export const metadata = { title: "Import" };
export default async function ImportSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Der Import schreibt Stammdaten — ohne dieses Recht gibt es die Seite nicht.
if (!hatRecht(sitzung, "geraet.bearbeiten")) redirect("/geraete");
return (
<div className="mx-auto max-w-5xl space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Daten importieren</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Aus einer vorhandenen Excel- oder CSV-Tabelle. Vor dem Übernehmen wird
angezeigt, was wohin geschrieben würde — geschrieben wird erst danach.
</p>
</div>
<ImportAssistent />
</div>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { GeraeteTabelle } from "@/app/(app)/geraete/geraete-tabelle";
export const metadata = { title: "Geräte" };
/**
* Geräteliste. Die eigentliche Tabelle ist eine Client-Komponente, weil
* Sortieren, Filtern und Auswählen ohne Zustand im Browser nicht geht. Die
* Rechteprüfung passiert hier auf dem Server — was die Komponente bekommt,
* ist nur die Antwort auf "darf diese Person bearbeiten".
*/
export default async function GeraeteSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Geräte</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Alle Geräte der Wehr mit Prüfterminen, Lebensdauer und Lagerung.
</p>
</div>
<GeraeteTabelle darfBearbeiten={hatRecht(sitzung, "geraet.bearbeiten")} />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Scanner } from "@/app/(app)/geraete/scannen/scanner";
export const metadata = { title: "Gerät scannen" };
export default function ScannenSeite() {
return (
<div className="mx-auto max-w-2xl space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/geraete">
<ArrowLeft aria-hidden />
Zurück zur Liste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Gerät scannen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
QR-Code auf dem Geräteetikett vor die Kamera halten. Die Gerätedetailseite
öffnet sich dann von selbst.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Kamera</CardTitle>
<CardDescription>
Der Browser fragt beim ersten Mal nach der Freigabe. Das Bild verlässt das
Gerät nicht — ausgewertet wird direkt im Browser.
</CardDescription>
</CardHeader>
<CardContent>
<Scanner />
</CardContent>
</Card>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { BrowserQRCodeReader } from "@zxing/browser";
import type { Result } from "@zxing/library";
import { Camera, CameraOff, KeyboardIcon, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Fehlermeldung } from "@/components/stammdaten/anzeige";
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
/**
* QR-Scanner über die Kamera.
*
* Bewusst mit @zxing/browser statt der eingebauten BarcodeDetector-API: die
* gibt es in Firefox und in Safari auf dem iPad nicht, und im Gerätehaus steht
* genau so ein Tablet.
*
* Zwei Dinge, die in der Praxis zählen:
* - Die Kamera wird beim Verlassen der Seite wirklich freigegeben. Sonst
* bleibt die Leuchte an und der nächste Aufruf bekommt kein Bild.
* - Es gibt immer einen Weg ohne Kamera. Wenn die Freigabe verweigert wurde
* oder das Etikett zerkratzt ist, tippt man die Nummer ein.
*/
export function Scanner() {
const router = useRouter();
const videoRef = React.useRef<HTMLVideoElement>(null);
const steuerungRef = React.useRef<{ stop: () => void } | null>(null);
const [laeuft, setLaeuft] = React.useState(false);
const [fehler, setFehler] = React.useState<string | null>(null);
const [letzterScan, setLetzterScan] = React.useState<string | null>(null);
const [eingabe, setEingabe] = React.useState("");
const stoppen = React.useCallback(() => {
steuerungRef.current?.stop();
steuerungRef.current = null;
setLaeuft(false);
}, []);
// Kamera in jedem Fall freigeben, auch bei einem Wechsel per Zurück-Taste.
React.useEffect(() => stoppen, [stoppen]);
const oeffnen = React.useCallback(
(inventarnummer: string) => {
stoppen();
router.push(`/g/${encodeURIComponent(inventarnummer)}`);
},
[router, stoppen],
);
async function starten() {
setFehler(null);
setLetzterScan(null);
if (!videoRef.current) return;
if (!navigator.mediaDevices?.getUserMedia) {
setFehler(
"Dieser Browser gibt keinen Kamerazugriff frei. Bitte die Inventarnummer eintippen.",
);
return;
}
try {
const leser = new BrowserQRCodeReader();
const steuerung = await leser.decodeFromVideoDevice(
undefined,
videoRef.current,
(ergebnis: Result | undefined) => {
if (!ergebnis) return;
const roh = ergebnis.getText();
const nummer = inventarnummerAusScan(roh);
if (!nummer) {
// Fremde Codes kommen ständig vor — der Hersteller-Barcode klebt
// oft direkt daneben. Kein Abbruch, nur ein Hinweis.
setLetzterScan(roh);
return;
}
oeffnen(nummer);
},
);
steuerungRef.current = steuerung;
setLaeuft(true);
} catch (ausnahme) {
const name = (ausnahme as { name?: string })?.name;
if (name === "NotAllowedError") {
setFehler(
"Der Kamerazugriff wurde abgelehnt. Er lässt sich in den Browsereinstellungen wieder freigeben — oder Sie tippen die Inventarnummer ein.",
);
} else if (name === "NotFoundError") {
setFehler("An diesem Gerät wurde keine Kamera gefunden.");
} else {
setFehler(`Die Kamera ließ sich nicht öffnen: ${String(ausnahme)}`);
}
}
}
function eingabeAbsenden(ereignis: React.FormEvent) {
ereignis.preventDefault();
const nummer = inventarnummerAusScan(eingabe);
if (!nummer) {
setFehler("Das sieht nicht nach einer Inventarnummer aus.");
return;
}
oeffnen(nummer);
}
return (
<div className="space-y-6">
{fehler && <Fehlermeldung meldung={fehler} />}
<div className="overflow-hidden rounded-lg border border-[var(--border)] bg-black">
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
ref={videoRef}
className="aspect-video w-full object-cover"
playsInline
muted
aria-label="Kamerabild zum Scannen"
/>
</div>
<div className="flex flex-wrap gap-2">
{laeuft ? (
<Button variant="outline" onClick={stoppen}>
<CameraOff aria-hidden />
Kamera anhalten
</Button>
) : (
<Button onClick={starten}>
<Camera aria-hidden />
Kamera starten
</Button>
)}
{laeuft && (
<Button variant="ghost" onClick={() => void starten()}>
<RefreshCw aria-hidden />
Neu starten
</Button>
)}
</div>
{letzterScan && (
<p className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
Erkannt, aber kein FLORIAN-Etikett:{" "}
<span className="break-all font-mono text-xs">{letzterScan}</span>
<br />
<span className="text-[var(--muted-foreground)]">
Vermutlich der Barcode des Herstellers. Bitte den Aufkleber mit dem FLORIAN-Code
scannen.
</span>
</p>
)}
<form
onSubmit={eingabeAbsenden}
className="space-y-2 rounded-lg border border-[var(--border)] p-4"
>
<Label htmlFor="inventarnummer" className="flex items-center gap-2">
<KeyboardIcon className="size-4" aria-hidden />
Oder Inventarnummer eintippen
</Label>
<div className="flex gap-2">
<Input
id="inventarnummer"
value={eingabe}
onChange={(ereignis) => setEingabe(ereignis.target.value)}
placeholder="INV-0042"
autoComplete="off"
/>
<Button type="submit" disabled={!eingabe.trim()}>
Öffnen
</Button>
</div>
<p className="text-xs text-[var(--muted-foreground)]">
Funktioniert auch ohne Kamera — praktisch, wenn das Etikett unleserlich geworden ist.
</p>
</form>
</div>
);
}
+3
View File
@@ -4,6 +4,7 @@ import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz"; import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste"; import { Seitenleiste } from "@/components/layout/seitenleiste";
import { ThemeToggle } from "@/components/layout/theme-toggle"; import { ThemeToggle } from "@/components/layout/theme-toggle";
import { TrpcProvider } from "@/lib/trpc/provider";
/** /**
* Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist, * Layout für alles, was eine Anmeldung verlangt. Wer nicht angemeldet ist,
@@ -23,6 +24,7 @@ export default async function AngemeldetLayout({
); );
return ( return (
<TrpcProvider>
<div className="min-h-screen"> <div className="min-h-screen">
<Seitenleiste <Seitenleiste
rechte={[...rechteVon(sitzung.rollen)]} rechte={[...rechteVon(sitzung.rollen)]}
@@ -38,5 +40,6 @@ export default async function AngemeldetLayout({
<main className="p-6">{children}</main> <main className="p-6">{children}</main>
</div> </div>
</div> </div>
</TrpcProvider>
); );
} }
+211
View File
@@ -0,0 +1,211 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { darfPersonSehen } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personAnrede } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
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.
*
* Die Zugriffsprüfung läuft über darfPersonSehen(): die eigenen Daten darf
* jeder sehen, fremde nur mit dem Recht person.lesen. Adressen und
* Geburtsdatum sind personenbezogene Daten — sie stehen deshalb hinter
* derselben Schranke wie alles andere.
*/
export default async function MitgliedSeite({ params }: { params: Promise<{ id: string }> }) {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
const { id } = await params;
if (!darfPersonSehen(sitzung, id)) redirect("/mitglieder");
const person = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findUnique({
where: { id },
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
telefon: true,
mobil: true,
strasse: true,
plz: true,
ort: true,
geburtsdatum: true,
eintrittsdatum: true,
austrittsdatum: true,
aktiv: true,
letzteAnmeldung: true,
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: true },
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
},
ausweisTokens: {
where: { geloeschtAm: null, aktiv: true },
select: { id: true, typ: true, bezeichnung: true, letzteNutzung: true },
},
},
}),
);
if (!person) notFound();
const angaben: Array<[string, React.ReactNode]> = [
["Mitgliedsnummer", <span className="tabellenzahlen">{person.mitgliedsnummer ?? "—"}</span>],
["Dienstgrad", DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad],
["Abteilung", person.abteilung ?? "—"],
["Eintritt", alsDatum(person.eintrittsdatum)],
["Geburtsdatum", alsDatum(person.geburtsdatum)],
["E-Mail", person.email ?? "—"],
["Telefon", person.telefon ?? "—"],
["Mobil", person.mobil ?? "—"],
[
"Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
];
return (
<div className="space-y-6">
<div>
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-1">
<Link href="/mitglieder">
<ArrowLeft aria-hidden />
Zurück zur Mitgliederliste
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
<div className="mt-1 flex flex-wrap items-center gap-2">
{person.rollen.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
{!person.aktiv && <Badge variant="ausgesondert">ausgetreten</Badge>}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Angaben</CardTitle>
</CardHeader>
<CardContent>
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
{angaben.map(([bezeichnung, wert]) => (
<div key={bezeichnung}>
<dt className="text-xs uppercase tracking-wide text-[var(--muted-foreground)]">
{bezeichnung}
</dt>
<dd className="text-sm">{wert}</dd>
</div>
))}
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Terminal-Zugang</CardTitle>
<CardDescription>
Anmeldemittel für das Gerätehaus. Gespeichert wird nur ein Hash.
</CardDescription>
</CardHeader>
<CardContent>
{person.ausweisTokens.length === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">
Kein Terminal-Zugang hinterlegt.
</p>
) : (
<ul className="space-y-2 text-sm">
{person.ausweisTokens.map((token) => (
<li key={token.id} className="flex items-center justify-between gap-2">
<span>
<Badge variant="outline">{token.typ}</Badge>{" "}
{token.bezeichnung ?? ""}
</span>
<span className="text-xs text-[var(--muted-foreground)]">
{token.letzteNutzung ? alsDatum(token.letzteNutzung) : "noch nicht benutzt"}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Qualifikationen</CardTitle>
<CardDescription>
Lehrgänge, Befähigungen und Untersuchungen samt Gültigkeit.
</CardDescription>
</CardHeader>
<CardContent>
{person.qualifikationen.length === 0 ? (
<NichtsGefunden
titel="Keine Qualifikationen erfasst"
hinweis="Sobald Lehrgänge oder Untersuchungen eingetragen sind, erscheinen sie hier mit ihrer Gültigkeit."
/>
) : (
<ul className="divide-y divide-[var(--border)]">
{person.qualifikationen.map((qualifikation) => (
<li
key={qualifikation.id}
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 text-sm"
>
<span className="font-medium">{qualifikation.typ.name}</span>
{qualifikation.typ.kuerzel && (
<Badge variant="outline">{qualifikation.typ.kuerzel}</Badge>
)}
<span className="text-[var(--muted-foreground)]">
erworben {alsDatum(qualifikation.erworbenAm)}
</span>
<span className="ml-auto">
{qualifikation.gueltigBis ? (
<FristAnzeige
datum={qualifikation.gueltigBis}
vorwarnTage={qualifikation.typ.vorwarnzeitTage}
/>
) : (
<span className="text-[var(--muted-foreground)]">unbefristet</span>
)}
</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personName } from "@/lib/utils";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { NichtsGefunden } from "@/components/stammdaten/anzeige";
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.
*
* Aktive, Jugendfeuerwehr und Passive werden getrennt geführt — eine
* durchlaufende alphabetische Liste vermischt Gruppen, die in der Wehr nichts
* miteinander zu tun haben.
*/
export default async function MitgliederSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "person.lesen")) redirect("/");
const personen = await fuerOrganisation(sitzung.organisationId, (db) =>
db.person.findMany({
where: { geloeschtAm: null },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
mobil: true,
eintrittsdatum: true,
aktiv: true,
rollen: { select: { rolle: { select: { name: true, schluessel: true } } } },
_count: { select: { qualifikationen: true } },
},
}),
);
const nachAbteilung = new Map<string, typeof personen>();
for (const person of personen) {
const gruppe = person.abteilung ?? "Ohne Abteilung";
if (!nachAbteilung.has(gruppe)) nachAbteilung.set(gruppe, []);
nachAbteilung.get(gruppe)!.push(person);
}
const reihenfolge = ["Aktive", "Jugendfeuerwehr", "Passive", "Verwaltung", "Ohne Abteilung"];
const gruppen = [...nachAbteilung.entries()].sort((a, b) => {
const ia = reihenfolge.indexOf(a[0]);
const ib = reihenfolge.indexOf(b[0]);
if (ia === -1 && ib === -1) return a[0].localeCompare(b[0], "de");
if (ia === -1) return 1;
if (ib === -1) return -1;
return ia - ib;
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitglieder</h1>
<p className="text-sm text-[var(--muted-foreground)]">
{personen.length} {personen.length === 1 ? "Mitglied" : "Mitglieder"}, nach
Abteilung geordnet.
</p>
</div>
{personen.length === 0 ? (
<NichtsGefunden
titel="Noch keine Mitglieder erfasst"
hinweis="Mitglieder lassen sich einzeln anlegen oder aus einer vorhandenen Liste importieren."
/>
) : (
gruppen.map(([abteilung, liste]) => (
<section key={abteilung} className="space-y-2">
<h2 className="text-sm font-medium uppercase tracking-wide text-[var(--muted-foreground)]">
{abteilung} ({liste.length})
</h2>
<div className="rounded-lg border border-[var(--border)]">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Nr.</TableHead>
<TableHead>Dienstgrad</TableHead>
<TableHead>Funktion</TableHead>
<TableHead>Kontakt</TableHead>
<TableHead className="text-right">Eintritt</TableHead>
<TableHead className="text-right">Qualifikationen</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{liste.map((person) => (
<TableRow key={person.id} className={!person.aktiv ? "opacity-60" : undefined}>
<TableCell className="font-medium">
<Link
href={`/mitglieder/${person.id}`}
className="underline-offset-4 hover:underline"
>
{personName(person)}
</Link>
</TableCell>
<TableCell className="tabellenzahlen text-[var(--muted-foreground)]">
{person.mitgliedsnummer ?? "—"}
</TableCell>
<TableCell className="text-sm">
{DIENSTGRAD_TEXT[person.dienstgrad] ?? person.dienstgrad}
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{person.rollen
.filter((zuweisung) => zuweisung.rolle.schluessel !== "MITGLIED")
.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
{zuweisung.rolle.name}
</Badge>
))}
</div>
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{person.mobil ?? person.email ?? "—"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(person.eintrittsdatum)}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{person._count.qualifikationen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
))
)}
</div>
);
}
+4 -4
View File
@@ -90,15 +90,15 @@ export default async function UebersichtSeite() {
<CardHeader> <CardHeader>
<CardTitle>Stand des Aufbaus</CardTitle> <CardTitle>Stand des Aufbaus</CardTitle>
<CardDescription> <CardDescription>
Phase 0 ist abgeschlossen: Fundament, Mandantentrennung, Anmeldung, Phase 1 ist abgeschlossen: Standorte, Fahrzeuge mit Geräteräumen,
Dateiablage und Protokollierung stehen. Die Fachmodule kommen Phase für Gerätetypen, Geräte mit QR-Etiketten, Qualifikationen und der Import aus
Phase dazu. vorhandenen Tabellen stehen. Als Nächstes kommt das Prüfmanagement.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 text-sm"> <CardContent className="space-y-3 text-sm">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Badge variant="einsatzbereit">Phase 0 — Fundament</Badge> <Badge variant="einsatzbereit">Phase 0 — Fundament</Badge>
<Badge variant="outline">Phase 1 — Stammdaten</Badge> <Badge variant="einsatzbereit">Phase 1 — Stammdaten</Badge>
<Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge> <Badge variant="outline">Phase 2 — Wartung und Prüfung</Badge>
<Badge variant="outline">Phase 3 — Einsätze</Badge> <Badge variant="outline">Phase 3 — Einsätze</Badge>
<Badge variant="outline">Phase 4 — Terminal</Badge> <Badge variant="outline">Phase 4 — Terminal</Badge>
+232
View File
@@ -0,0 +1,232 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AlertTriangle } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personName } from "@/lib/utils";
import { qualifikationZustand } from "@/lib/stammdaten/fristen";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
export const metadata = { title: "Qualifikationen" };
const ART_TEXT: Record<string, string> = {
LEHRGANG: "Lehrgang",
BEFAEHIGUNG: "Befähigung",
UNTERSUCHUNG: "Untersuchung",
FUEHRERSCHEIN: "Führerschein",
UNTERWEISUNG: "Unterweisung",
SONSTIGES: "Sonstiges",
};
/**
* Qualifikationen mit Ablaufwarnung.
*
* Oben steht, was abgelaufen ist oder demnächst abläuft — das ist der Grund,
* warum jemand diese Seite öffnet. Der Katalog der Typen steht darunter.
*
* Gewarnt wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer
* pauschalen Frist: eine G26.3-Untersuchung braucht mehr Vorlauf als eine
* jährliche Unterweisung, weil erst ein Termin beim Arzt gefunden werden muss.
*/
export default async function QualifikationenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "qualifikation.lesen")) redirect("/");
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const in180Tagen = new Date(Date.now() + 180 * 864e5);
const [ablaufend, typen] = await Promise.all([
db.qualifikation.findMany({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: in180Tagen },
person: { aktiv: true, geloeschtAm: null },
},
include: {
typ: {
select: {
id: true,
name: true,
kuerzel: true,
art: true,
vorwarnzeitTage: true,
einsatzrelevant: true,
},
},
person: { select: { id: true, vorname: true, nachname: true } },
},
orderBy: { gueltigBis: "asc" },
take: 100,
}),
db.qualifikationstyp.findMany({
where: { geloeschtAm: null },
orderBy: [{ art: "asc" }, { name: "asc" }],
include: { _count: { select: { qualifikationen: true } } },
}),
]);
return { ablaufend, typen };
});
const dringend = daten.ablaufend.filter((eintrag) => {
const zustand = qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage);
return zustand === "ueberfaellig" || zustand === "faellig" || zustand === "bald_faellig";
});
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Qualifikationen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Lehrgänge, Befähigungen und Untersuchungen der Mannschaft.
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-start gap-3">
<AlertTriangle
className={
dringend.length > 0
? "mt-0.5 size-5 shrink-0 text-[var(--pruefung-faellig)]"
: "mt-0.5 size-5 shrink-0 text-[var(--muted-foreground)]"
}
aria-hidden
/>
<div>
<CardTitle>Läuft ab</CardTitle>
<CardDescription>
{dringend.length === 0
? "Derzeit läuft nichts innerhalb der jeweiligen Vorwarnzeit ab."
: `${dringend.length} ${dringend.length === 1 ? "Qualifikation ist" : "Qualifikationen sind"} innerhalb der Vorwarnzeit oder bereits abgelaufen.`}
</CardDescription>
</div>
</div>
</CardHeader>
{dringend.length > 0 && (
<CardContent className="px-0 pb-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Mitglied</TableHead>
<TableHead>Qualifikation</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Erworben</TableHead>
<TableHead className="pr-6 text-right">Gültig bis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dringend.map((eintrag) => (
<TableRow key={eintrag.id}>
<TableCell className="pl-6 font-medium">
<Link
href={`/mitglieder/${eintrag.person.id}`}
className="underline-offset-4 hover:underline"
>
{personName(eintrag.person)}
</Link>
</TableCell>
<TableCell>
{eintrag.typ.name}
{eintrag.typ.einsatzrelevant && (
<Badge variant="gesperrt" className="ml-2">
einsatzrelevant
</Badge>
)}
</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{ART_TEXT[eintrag.typ.art] ?? eintrag.typ.art}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{alsDatum(eintrag.erworbenAm)}
</TableCell>
<TableCell className="pr-6 text-right">
<FristAnzeige
datum={eintrag.gueltigBis}
vorwarnTage={eintrag.typ.vorwarnzeitTage}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
)}
</Card>
<Card>
<CardHeader>
<CardTitle>Katalog</CardTitle>
<CardDescription>
Welche Qualifikationen die Wehr führt, und wie lange sie jeweils gelten.
</CardDescription>
</CardHeader>
<CardContent className="px-0 pb-0">
{daten.typen.length === 0 ? (
<div className="px-6 pb-6">
<NichtsGefunden
titel="Noch keine Qualifikationstypen angelegt"
hinweis="Erst wenn Typen wie „Maschinist“ oder „G26.3“ angelegt sind, lassen sich Qualifikationen zuweisen."
/>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="pl-6">Bezeichnung</TableHead>
<TableHead>Kürzel</TableHead>
<TableHead>Art</TableHead>
<TableHead className="text-right">Gültigkeit</TableHead>
<TableHead className="text-right">Vorwarnzeit</TableHead>
<TableHead className="pr-6 text-right">Erfasst</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{daten.typen.map((typ) => (
<TableRow key={typ.id}>
<TableCell className="pl-6 font-medium">
{typ.name}
{typ.einsatzrelevant && (
<Badge variant="secondary" className="ml-2">
einsatzrelevant
</Badge>
)}
</TableCell>
<TableCell className="tabellenzahlen">{typ.kuerzel ?? "—"}</TableCell>
<TableCell className="text-sm text-[var(--muted-foreground)]">
{ART_TEXT[typ.art] ?? typ.art}
</TableCell>
<TableCell className="text-right">
{typ.gueltigkeitMonate
? `${typ.gueltigkeitMonate} Monate`
: "unbefristet"}
</TableCell>
<TableCell className="text-right tabellenzahlen">
{typ.vorwarnzeitTage} Tage
</TableCell>
<TableCell className="pr-6 text-right tabellenzahlen">
{typ._count.qualifikationen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { hatRecht } from "@/lib/authz";
import { etikettenPdf, ETIKETTEN_JE_SEITE } from "@/lib/stammdaten/etiketten";
import { AuditAktion, protokolliere } from "@/lib/audit";
/**
* Etikettenbogen als PDF.
*
* Eigener Endpunkt statt einer tRPC-Prozedur: Ein PDF ist eine Datei, keine
* JSON-Antwort. Über tRPC müsste es Base64-kodiert durch die Leitung, und der
* Browser könnte es nicht einfach in einem neuen Tab öffnen.
*
* Nimmt zwei Formen entgegen:
* - JSON { ids: [...], startPosition?: n } aus der Geräteliste
* - Formular mit einem Feld "id" von der Detailseite
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const eingabe = z.object({
ids: z.array(z.string().uuid()).min(1).max(500),
/** Ab welchem Etikett des Bogens gedruckt wird — für angebrochene Bögen. */
startPosition: z.number().int().min(1).max(ETIKETTEN_JE_SEITE).default(1),
});
export async function POST(anfrage: Request) {
const sitzung = await aktuelleSitzung();
if (!sitzung) {
return NextResponse.json({ fehler: "Bitte zuerst anmelden." }, { status: 401 });
}
if (!hatRecht(sitzung, "geraet.lesen")) {
return NextResponse.json({ fehler: "Fehlende Berechtigung." }, { status: 403 });
}
let daten: z.infer<typeof eingabe>;
const inhaltstyp = anfrage.headers.get("content-type") ?? "";
if (inhaltstyp.includes("application/json")) {
const geprueft = eingabe.safeParse(await anfrage.json());
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
{ status: 400 },
);
}
daten = geprueft.data;
} else {
const formular = await anfrage.formData();
const id = formular.get("id");
const start = Number(formular.get("startPosition") ?? 1);
const geprueft = eingabe.safeParse({
ids: typeof id === "string" ? [id] : [],
startPosition: Number.isFinite(start) ? start : 1,
});
if (!geprueft.success) {
return NextResponse.json(
{ fehler: "Es wurde kein gültiges Gerät übergeben." },
{ status: 400 },
);
}
daten = geprueft.data;
}
const { geraete, wehrname } = await fuerOrganisation(sitzung.organisationId, async (db) => {
const [liste, organisation] = await Promise.all([
db.geraet.findMany({
where: { id: { in: daten.ids }, geloeschtAm: null },
select: {
inventarnummer: true,
bezeichnung: true,
fahrzeug: { select: { funkrufname: true } },
standort: { select: { kuerzel: true, name: true } },
},
orderBy: { inventarnummer: "asc" },
}),
db.organisation.findUnique({
where: { id: sitzung.organisationId },
select: { name: true, kurzname: true },
}),
]);
return {
geraete: liste,
wehrname: organisation?.kurzname ?? organisation?.name ?? "Feuerwehr",
};
});
if (geraete.length === 0) {
return NextResponse.json({ fehler: "Keines dieser Geräte gibt es." }, { status: 404 });
}
try {
const pdf = await etikettenPdf(
geraete.map((geraet) => ({
inventarnummer: geraet.inventarnummer,
bezeichnung: geraet.bezeichnung,
zusatz: geraet.fahrzeug?.funkrufname ?? geraet.standort?.kuerzel ?? geraet.standort?.name,
})),
wehrname,
daten.startPosition,
);
// Der Abruf von Etiketten ist protokollpflichtig: daran lässt sich später
// nachvollziehen, wann welche Aufkleber gedruckt wurden.
await protokolliere({
organisationId: sitzung.organisationId,
aktion: AuditAktion.EXPORTIERT,
entitaet: "Geraet",
beschreibung: `Etikettenbogen für ${geraete.length} Geräte erzeugt`,
sitzung,
kontext: { anzahl: geraete.length, startPosition: daten.startPosition },
});
return new NextResponse(new Uint8Array(pdf), {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="etiketten-${geraete.length}.pdf"`,
"Cache-Control": "no-store",
},
});
} catch (fehler) {
console.error("[etiketten] PDF fehlgeschlagen:", fehler);
return NextResponse.json(
{
fehler:
"Der Etikettenbogen ließ sich nicht erzeugen. Läuft der Gotenberg-Container? (docker compose ps)",
},
{ status: 502 },
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { QrCode } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
/**
* Ziel der QR-Codes auf den Geräteetiketten: /g/<inventarnummer>
*
* Die Route leitet nur weiter. Sie ist bewusst kurz, damit der Code auf einem
* 38 × 21 mm großen Etikett mit dicken Modulen auskommt — je länger die URL,
* desto feiner das Muster und desto unzuverlässiger der Scan im Geräteraum.
*
* Wichtig: Die Adresse ist keine Berechtigung. Wer nicht angemeldet ist, landet
* auf der Anmeldeseite und kommt danach hierher zurück.
*/
export const metadata = { title: "Gerät suchen" };
export default async function QrZielSeite({
params,
}: {
params: Promise<{ nummer: string }>;
}) {
const { nummer } = await params;
const inventarnummer = decodeURIComponent(nummer);
const sitzung = await aktuelleSitzung();
if (!sitzung) {
redirect(`/anmelden?callbackUrl=${encodeURIComponent(`/g/${nummer}`)}`);
}
const geraet = await fuerOrganisation(sitzung.organisationId, (db) =>
db.geraet.findFirst({
where: { inventarnummer, geloeschtAm: null },
select: { id: true },
}),
);
if (geraet) redirect(`/geraete/${geraet.id}`);
// Kein Treffer: das kann ein Etikett einer anderen Wehr sein, ein Gerät, das
// ausgesondert wurde, oder ein Tippfehler. Alle drei Fälle sehen für den
// Scanner gleich aus — deshalb eine Meldung statt einer 404-Seite.
return (
<div className="mx-auto max-w-md">
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<QrCode className="size-6 text-[var(--muted-foreground)]" aria-hidden />
<div>
<CardTitle>Kein Gerät gefunden</CardTitle>
<CardDescription>
Zur Inventarnummer{" "}
<span className="tabellenzahlen font-medium">{inventarnummer}</span> gibt es in
dieser Wehr kein Gerät.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<ul className="list-disc space-y-1 pl-5 text-sm text-[var(--muted-foreground)]">
<li>Das Etikett gehört zu einer anderen Wehr.</li>
<li>Das Gerät wurde ausgesondert und ist nicht mehr in der Liste.</li>
<li>Die Inventarnummer wurde beim Anlegen anders geschrieben.</li>
</ul>
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link href={`/geraete?suche=${encodeURIComponent(inventarnummer)}`}>
In der Liste suchen
</Link>
</Button>
<Button variant="outline" asChild>
<Link href="/geraete/scannen">Nochmal scannen</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
+6 -3
View File
@@ -15,6 +15,7 @@ import {
MonitorSmartphone, MonitorSmartphone,
Siren, Siren,
Truck, Truck,
Wallet,
Users, Users,
Wrench, Wrench,
X, X,
@@ -31,7 +32,7 @@ import type { Recht, Rolle } from "@/lib/authz";
* bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter * bleibt in lib/authz.ts, wie es die Projektregeln verlangen. Ein ausgeblendeter
* Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht. * Eintrag ist Bequemlichkeit, kein Schutz; der Schutz sitzt in der tRPC-Schicht.
* *
* Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-8.md. * Die Phasenangaben entsprechen den Dateien Phase-0.md bis Phase-9.md.
*/ */
interface Eintrag { interface Eintrag {
@@ -86,6 +87,7 @@ const EINTRAEGE: Eintrag[] = [
rechte: ["ausbildung.lesen"], rechte: ["ausbildung.lesen"],
abPhase: 7, abPhase: 7,
}, },
{ titel: "Kasse", pfad: "/kasse", symbol: Wallet, rechte: ["kasse.lesen"], abPhase: 9 },
{ {
titel: "Protokoll", titel: "Protokoll",
pfad: "/protokoll", pfad: "/protokoll",
@@ -95,8 +97,9 @@ const EINTRAEGE: Eintrag[] = [
}, },
]; ];
/** In Phase 0 gibt es nur die Übersicht — alles andere ist noch Ausblick. */ /** Bis zu dieser Phase gibt es die Ansichten wirklich. Alles darüber ist
const AKTUELLE_PHASE = 0; * sichtbar, aber ausgegraut — so sieht die Wehr, was noch kommt. */
const AKTUELLE_PHASE = 1;
export interface SeitenleisteProps { export interface SeitenleisteProps {
rechte: Recht[]; rechte: Recht[];
+144
View File
@@ -0,0 +1,144 @@
"use client";
import * as React from "react";
import type { GeraetStatus } from "@prisma/client";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { alsDatum } from "@/lib/utils";
import { fristzustand, tageBis, type Fristzustand } from "@/lib/stammdaten/fristen";
/**
* Anzeigebausteine für Stammdaten.
*
* Farbe bedeutet überall dasselbe: grün einsatzbereit, gelb läuft ab, rot
* gesperrt oder überfällig, grau ausgesondert. Wer die Farbe einmal gelernt
* hat, muss den Text nicht mehr lesen — und genau darum geht es bei einer
* Liste mit dreihundert Zeilen.
*
* Farbe allein reicht aber nicht: jede Angabe trägt zusätzlich Klartext.
* Rot-Grün-Sehschwäche ist häufig, und im Gerätehaus arbeitet niemand mit
* kalibriertem Bildschirm.
*/
const STATUS_TEXT: Record<GeraetStatus, string> = {
EINSATZBEREIT: "einsatzbereit",
PRUEFUNG_FAELLIG: "Prüfung fällig",
GESPERRT: "gesperrt",
AUSGESONDERT: "ausgesondert",
IN_REPARATUR: "in Reparatur",
};
const STATUS_VARIANTE: Record<
GeraetStatus,
"einsatzbereit" | "pruefungFaellig" | "gesperrt" | "ausgesondert" | "secondary"
> = {
EINSATZBEREIT: "einsatzbereit",
PRUEFUNG_FAELLIG: "pruefungFaellig",
GESPERRT: "gesperrt",
AUSGESONDERT: "ausgesondert",
IN_REPARATUR: "secondary",
};
export function StatusAbzeichen({ status }: { status: GeraetStatus }) {
return <Badge variant={STATUS_VARIANTE[status]}>{STATUS_TEXT[status]}</Badge>;
}
export const GERAET_STATUS_TEXT = STATUS_TEXT;
/**
* Ein Datum mit Ampel. Zeigt zusätzlich, wie viele Tage noch bleiben —
* "in 12 Tagen" sagt mehr als ein Datum, das man erst im Kopf ausrechnen muss.
*/
export function FristAnzeige({
datum,
vorwarnTage = 30,
ohneAngabe = "—",
}: {
datum: Date | string | null | undefined;
vorwarnTage?: number;
ohneAngabe?: string;
}) {
if (!datum) {
return <span className="text-[var(--muted-foreground)]">{ohneAngabe}</span>;
}
const alsDate = datum instanceof Date ? datum : new Date(datum);
const zustand = fristzustand(alsDate, vorwarnTage);
const tage = tageBis(alsDate);
const farbe: Record<Fristzustand, string> = {
ueberfaellig: "text-[var(--gesperrt)] font-medium",
faellig: "text-[var(--gesperrt)] font-medium",
bald_faellig: "text-[var(--pruefung-faellig)]",
in_ordnung: "",
ohne_frist: "text-[var(--muted-foreground)]",
};
const zusatz =
tage === null
? ""
: tage < 0
? `seit ${Math.abs(tage)} ${Math.abs(tage) === 1 ? "Tag" : "Tagen"}`
: tage === 0
? "heute"
: `in ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
return (
<span className={cn("tabellenzahlen whitespace-nowrap", farbe[zustand])}>
{alsDatum(alsDate)}
{zusatz && (
<span className="ml-1.5 text-xs text-[var(--muted-foreground)]">({zusatz})</span>
)}
</span>
);
}
/** Leerer Zustand einer Liste — mit einem Hinweis, was als Nächstes zu tun ist. */
export function NichtsGefunden({
titel,
hinweis,
aktion,
}: {
titel: string;
hinweis?: string;
aktion?: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[var(--border)] px-6 py-16 text-center">
<p className="font-medium">{titel}</p>
{hinweis && (
<p className="max-w-md text-sm text-[var(--muted-foreground)]">{hinweis}</p>
)}
{aktion}
</div>
);
}
/** Platzhalterzeilen, solange geladen wird. */
export function LadeZeilen({ zeilen = 8, spalten = 6 }: { zeilen?: number; spalten?: number }) {
return (
<>
{Array.from({ length: zeilen }).map((_, zeile) => (
<tr key={zeile} className="border-b border-[var(--border)]">
{Array.from({ length: spalten }).map((__, spalte) => (
<td key={spalte} className="px-3 py-3">
<div className="h-4 animate-pulse rounded bg-[var(--muted)]" />
</td>
))}
</tr>
))}
</>
);
}
/** Fehlermeldung, die sagt, was schiefging — nicht nur, dass etwas schiefging. */
export function Fehlermeldung({ meldung }: { meldung: string }) {
return (
<div
role="alert"
className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm text-[var(--destructive)]"
>
{meldung}
</div>
);
}
+23 -14
View File
@@ -22,26 +22,35 @@ const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDiv
); );
CardHeader.displayName = "CardHeader"; CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( /**
({ className, ...props }, ref) => ( * Kartenüberschrift.
<div *
* Bewusst ein echtes h3 und kein div: Wer mit einer Sprachausgabe arbeitet,
* springt von Überschrift zu Überschrift. Eine Karte, deren Titel technisch
* keine Überschrift ist, taucht in dieser Navigation nicht auf — die Seite
* wirkt dann leer, obwohl sie voll ist.
*
* Über "as" lässt sich die Ebene anpassen, wenn die Karte im Dokument tiefer
* oder höher steht.
*/
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement> & { as?: "h2" | "h3" | "h4" }
>(({ className, as: Element = "h3", ...props }, ref) => (
<Element
ref={ref} ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)} className={cn("font-semibold leading-none tracking-tight", className)}
{...props} {...props}
/> />
), ));
);
CardTitle.displayName = "CardTitle"; CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardDescription = React.forwardRef<
({ className, ...props }, ref) => ( HTMLParagraphElement,
<div React.HTMLAttributes<HTMLParagraphElement>
ref={ref} >(({ className, ...props }, ref) => (
className={cn("text-sm text-[var(--muted-foreground)]", className)} <p ref={ref} className={cn("text-sm text-[var(--muted-foreground)]", className)} {...props} />
{...props} ));
/>
),
);
CardDescription.displayName = "CardDescription"; CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>( const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
+35
View File
@@ -0,0 +1,35 @@
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer size-4 shrink-0 rounded-sm border border-[var(--border)] shadow-sm",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ring)] focus-visible:ring-offset-1",
"disabled:cursor-not-allowed disabled:opacity-50",
"data-[state=checked]:border-[var(--primary)] data-[state=checked]:bg-[var(--primary)] data-[state=checked]:text-[var(--primary-foreground)]",
"data-[state=indeterminate]:border-[var(--primary)] data-[state=indeterminate]:bg-[var(--primary)] data-[state=indeterminate]:text-[var(--primary-foreground)]",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center text-current">
{props.checked === "indeterminate" ? (
<Minus className="size-3" aria-hidden />
) : (
<Check className="size-3" aria-hidden />
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+104
View File
@@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4",
"max-h-[90vh] overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--background)] p-6 shadow-lg",
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-[var(--ring)]"
aria-label="Schließen"
>
<X className="size-4" aria-hidden />
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
function DialogHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn("flex flex-col space-y-1.5", className)} {...props} />;
}
function DialogFooter({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
+105
View File
@@ -0,0 +1,105 @@
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-[var(--input)] bg-[var(--background)] px-3 py-1 text-sm shadow-sm",
"focus:outline-none focus:ring-2 focus:ring-[var(--ring)] focus:ring-offset-1 focus:ring-offset-[var(--background)]",
"disabled:cursor-not-allowed disabled:opacity-50",
"[&>span]:line-clamp-1 [&>span]:text-left",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 shrink-0 opacity-50" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-[var(--border)] bg-[var(--popover)] text-[var(--popover-foreground)] shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
position === "popper" && "data-[side=bottom]:translate-y-1",
className,
)}
{...props}
>
<SelectPrimitive.Viewport
className={cn("p-1", position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]")}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-xs font-medium text-[var(--muted-foreground)]", className)}
{...props}
/>
));
SelectLabel.displayName = SelectPrimitive.Label.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"focus:bg-[var(--accent)] focus:text-[var(--accent-foreground)]",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
};
+90
View File
@@ -0,0 +1,90 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Tabellenbausteine.
*
* Der Rahmen scrollt waagerecht, statt die Seite zu sprengen — eine
* Geräteliste mit zwölf Spalten passt auf keinem Tablet in die Breite, und
* eine umbrechende Tabelle ist unlesbar.
*/
const Table = React.forwardRef<HTMLTableElement, React.HTMLAttributes<HTMLTableElement>>(
({ className, ...props }, ref) => (
<div className="relative w-full overflow-x-auto">
<table
ref={ref}
className={cn("w-full caption-bottom border-collapse text-sm", className)}
{...props}
/>
</div>
),
);
Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody ref={ref} className={cn("[&_tr:last-child]:border-0", className)} {...props} />
));
TableBody.displayName = "TableBody";
const TableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b border-[var(--border)] transition-colors hover:bg-[var(--muted)]/60 data-[ausgewaehlt=true]:bg-[var(--accent)]",
className,
)}
{...props}
/>
),
);
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 whitespace-nowrap px-3 text-left align-middle text-xs font-medium uppercase tracking-wide text-[var(--muted-foreground)]",
className,
)}
{...props}
/>
));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td ref={ref} className={cn("px-3 py-2 align-middle", className)} {...props} />
));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-3 text-sm text-[var(--muted-foreground)]", className)}
{...props}
/>
));
TableCaption.displayName = "TableCaption";
export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption };
+1 -3
View File
@@ -114,9 +114,7 @@ export const RECHTE = {
"qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" }, "qualifikation.lesen": { bereich: "Ausbildung", bezeichnung: "Qualifikationen ansehen" },
"qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" }, "qualifikation.bearbeiten": { bereich: "Ausbildung", bezeichnung: "Qualifikationen pflegen" },
// Kasse — die Rolle KASSIER ist in Phase 0 gefordert, ein Kassenmodul ist // Kasse (Phase 9)
// in Phase-0.md bis Phase-8.md aber nicht vorgesehen. Die Rechte stehen hier
// bereits bereit, damit die Rolle nicht leer bleibt. Siehe docs/ROADMAP.md.
"kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" }, "kasse.lesen": { bereich: "Kasse", bezeichnung: "Buchungen und Auswertungen ansehen" },
"kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" }, "kasse.bearbeiten": { bereich: "Kasse", bezeichnung: "Buchungen erfassen" },
"kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" }, "kasse.abschluss": { bereich: "Kasse", bezeichnung: "Kassenabschluss durchführen" },
+121
View File
@@ -0,0 +1,121 @@
import "server-only";
import { env } from "@/lib/env";
/**
* PDF-Erzeugung über Gotenberg.
*
* Gotenberg ist ein eigener Container im Stack, der Chromium kapselt. Die
* Anwendung schickt fertiges HTML hin und bekommt ein PDF zurück — dadurch
* muss im Anwendungscontainer kein Browser stecken, und die Erzeugung kann
* abstürzen, ohne die Anwendung mitzunehmen.
*
* Regel für alle Vorlagen: das HTML muss vollständig eigenständig sein.
* Keine externen Schriften, keine CDN-Verweise, kein nachgeladenes CSS. Was
* nicht im übergebenen HTML steht, ist im PDF nicht drin.
*/
export interface PdfEinstellungen {
/** Seitengröße in Zoll. A4 ist 8.27 × 11.7. */
breite?: number;
hoehe?: number;
/** Ränder in Zoll. */
randOben?: number;
randUnten?: number;
randLinks?: number;
randRechts?: number;
quer?: boolean;
/** Hintergrundfarben und -bilder mitdrucken. */
hintergrundDrucken?: boolean;
/** Kopf- und Fußzeile als eigenes HTML-Fragment. */
kopfzeileHtml?: string;
fusszeileHtml?: string;
}
const A4 = { breite: 8.27, hoehe: 11.7 };
/**
* Rendert HTML zu PDF.
*
* @throws wenn Gotenberg nicht erreichbar ist oder das Zeitlimit reißt — der
* Aufrufer entscheidet, ob das ein Fehler für die Oberfläche ist oder
* ein Wiederholungsversuch im Hintergrundprozess.
*/
export async function htmlZuPdf(
html: string,
einstellungen: PdfEinstellungen = {},
): Promise<Buffer> {
if (!env.GOTENBERG_URL) {
throw new Error("GOTENBERG_URL ist nicht gesetzt — ohne Gotenberg gibt es keine PDFs.");
}
const formular = new FormData();
formular.append("files", new Blob([html], { type: "text/html" }), "index.html");
formular.append("paperWidth", String(einstellungen.breite ?? A4.breite));
formular.append("paperHeight", String(einstellungen.hoehe ?? A4.hoehe));
formular.append("marginTop", String(einstellungen.randOben ?? 0));
formular.append("marginBottom", String(einstellungen.randUnten ?? 0));
formular.append("marginLeft", String(einstellungen.randLinks ?? 0));
formular.append("marginRight", String(einstellungen.randRechts ?? 0));
formular.append("landscape", String(Boolean(einstellungen.quer)));
formular.append("printBackground", String(einstellungen.hintergrundDrucken ?? true));
// Ohne diese Einstellung wartet Chromium auf Netzwerkanfragen, die es bei
// eigenständigem HTML gar nicht gibt.
formular.append("skipNetworkIdleEvent", "true");
if (einstellungen.kopfzeileHtml) {
formular.append(
"files",
new Blob([einstellungen.kopfzeileHtml], { type: "text/html" }),
"header.html",
);
}
if (einstellungen.fusszeileHtml) {
formular.append(
"files",
new Blob([einstellungen.fusszeileHtml], { type: "text/html" }),
"footer.html",
);
}
const abbruch = AbortSignal.timeout(env.GOTENBERG_TIMEOUT * 1000);
const antwort = await fetch(`${env.GOTENBERG_URL}/forms/chromium/convert/html`, {
method: "POST",
body: formular,
signal: abbruch,
});
if (!antwort.ok) {
const text = await antwort.text().catch(() => "");
throw new Error(
`Gotenberg konnte das PDF nicht erzeugen (${antwort.status}). ${text.slice(0, 300)}`,
);
}
return Buffer.from(await antwort.arrayBuffer());
}
/** Erreichbarkeitsprüfung. */
export async function pdfDienstErreichbar(): Promise<boolean> {
if (!env.GOTENBERG_URL) return false;
try {
const antwort = await fetch(`${env.GOTENBERG_URL}/health`, {
signal: AbortSignal.timeout(3000),
});
return antwort.ok;
} catch {
return false;
}
}
/** Maskiert Text für die Einbettung in HTML. */
export function html(text: string | null | undefined): string {
if (!text) return "";
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
+205
View File
@@ -0,0 +1,205 @@
import "server-only";
import { htmlZuPdf, html } from "@/lib/pdf";
import { qrAlsSvg } from "@/lib/stammdaten/qr";
/**
* Etikettenbogen für Geräte.
*
* Zielformat ist der gängige Bogen mit 65 Etiketten je DIN-A4-Seite
* (38,1 × 21,2 mm, 5 Spalten × 13 Zeilen) — den gibt es in jedem Schreibwaren-
* laden, und er passt in jeden Laserdrucker.
*
* Auf jedes Etikett kommen drei Dinge, in dieser Reihenfolge der Wichtigkeit:
* 1. der QR-Code, damit das Gerät am Regal gescannt werden kann
* 2. die Inventarnummer, damit man sie auch ohne Handy ablesen kann
* 3. die Bezeichnung, gekürzt — auf 21 mm Höhe passt nicht mehr
*
* Die Etiketten sind absichtlich karg. Ein Aufkleber im Geräteraum bekommt
* Staub, Öl und Schrammen ab; alles, was nicht gebraucht wird, macht den Code
* nur kleiner.
*/
/** Maße des Bogens in Millimetern. */
export const BOGEN = {
spalten: 5,
zeilen: 13,
etikettBreite: 38.1,
etikettHoehe: 21.2,
randOben: 10.7,
randLinks: 4.75,
abstandX: 2.5,
abstandY: 0,
} as const;
export const ETIKETTEN_JE_SEITE = BOGEN.spalten * BOGEN.zeilen;
export interface EtikettAngaben {
inventarnummer: string;
bezeichnung: string;
/** Zusatzzeile, z. B. der Funkrufname des Fahrzeugs. */
zusatz?: string | null;
}
/**
* Baut den Bogen als eigenständiges HTML.
*
* @param startPosition Ab welchem Etikett gedruckt wird, 1-basiert. Damit
* lässt sich ein angebrochener Bogen weiterverwenden —
* sonst wandert jeder Bogen mit drei benutzten Etiketten
* in den Müll.
*/
export async function etikettenHtml(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<string> {
const leerVorne = Math.max(0, Math.min(startPosition - 1, ETIKETTEN_JE_SEITE - 1));
const zellen: string[] = [];
for (let i = 0; i < leerVorne; i += 1) {
zellen.push('<div class="etikett leer"></div>');
}
for (const geraet of geraete) {
const qr = await qrAlsSvg(geraet.inventarnummer, 16);
zellen.push(`
<div class="etikett">
<div class="qr">${qr}</div>
<div class="text">
<div class="nummer">${html(geraet.inventarnummer)}</div>
<div class="bezeichnung">${html(kuerzen(geraet.bezeichnung, 34))}</div>
${geraet.zusatz ? `<div class="zusatz">${html(kuerzen(geraet.zusatz, 28))}</div>` : ""}
</div>
</div>
`);
}
// Die letzte Seite auffüllen, damit das Raster nicht verrutscht.
while (zellen.length % ETIKETTEN_JE_SEITE !== 0) {
zellen.push('<div class="etikett leer"></div>');
}
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Etiketten — ${html(wehrname)}</title>
<style>
/*
Keine externen Schriften: gedruckt wird mit dem, was das System hergibt.
Eine geladene Schrift würde den Druck verzögern und im Zweifel gar nicht
ankommen.
*/
@page {
size: A4;
margin: 0;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
-webkit-print-color-adjust: exact;
}
.bogen {
width: 210mm;
padding-top: ${BOGEN.randOben}mm;
padding-left: ${BOGEN.randLinks}mm;
display: grid;
grid-template-columns: repeat(${BOGEN.spalten}, ${BOGEN.etikettBreite}mm);
grid-auto-rows: ${BOGEN.etikettHoehe}mm;
column-gap: ${BOGEN.abstandX}mm;
row-gap: ${BOGEN.abstandY}mm;
}
.etikett {
width: ${BOGEN.etikettBreite}mm;
height: ${BOGEN.etikettHoehe}mm;
padding: 1mm 1.2mm;
display: flex;
align-items: center;
gap: 1.2mm;
overflow: hidden;
}
.etikett.leer { visibility: hidden; }
.qr {
flex: 0 0 16mm;
width: 16mm;
height: 16mm;
display: flex;
align-items: center;
justify-content: center;
}
.qr svg { display: block; }
.text {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.3mm;
}
.nummer {
font-size: 7.5pt;
font-weight: 700;
letter-spacing: 0.02em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bezeichnung {
font-size: 5.5pt;
line-height: 1.15;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.zusatz {
font-size: 5pt;
color: #444;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
</head>
<body>
<div class="bogen">
${zellen.join("\n")}
</div>
</body>
</html>`;
}
/** Erzeugt den Bogen als PDF. */
export async function etikettenPdf(
geraete: EtikettAngaben[],
wehrname: string,
startPosition = 1,
): Promise<Buffer> {
const inhalt = await etikettenHtml(geraete, wehrname, startPosition);
return htmlZuPdf(inhalt, {
randOben: 0,
randUnten: 0,
randLinks: 0,
randRechts: 0,
hintergrundDrucken: true,
});
}
function kuerzen(text: string, zeichen: number): string {
if (text.length <= zeichen) return text;
return `${text.slice(0, zeichen - 1).trimEnd()}…`;
}
+255
View File
@@ -0,0 +1,255 @@
import { formatInTimeZone } from "date-fns-tz";
/**
* Fristen- und Lebensdauerberechnung.
*
* Bewusst reine Funktionen ohne Datenbankzugriff: was hier steht, entscheidet
* am Ende darüber, ob ein Gerät als einsatzbereit gilt oder gesperrt wird.
* Solche Logik gehört an eine Stelle, an der man sie ohne laufenden Stack
* durchtesten kann.
*
* Alle Berechnungen laufen serverseitig und rechnen auf Kalendertagen — die
* Uhrzeit spielt bei Prüffristen keine Rolle, das Datum schon.
*/
/**
* Alle Rechnungen laufen über UTC-Kalendertage.
*
* Der naheliegende Weg wäre startOfDay() aus date-fns — der rechnet aber in
* der Zeitzone des Servers. Ein Prüfdatum kommt aus der Datenbank als
* DATE-Spalte und damit als UTC-Mitternacht; startOfDay() in Europe/Berlin
* schiebt es auf den Vortag zurück. Aus dem 01.03. wird der 28.02., und eine
* Frist wäre einen Tag zu früh fällig.
*
* Deshalb hier eigene Arithmetik auf den UTC-Bestandteilen. Sie ist
* unabhängig davon, wo der Server steht.
*/
/** Setzt ein Datum auf UTC-Mitternacht seines eigenen Kalendertages. */
function utcTag(wert: Date): Date {
return new Date(Date.UTC(wert.getUTCFullYear(), wert.getUTCMonth(), wert.getUTCDate()));
}
/**
* Addiert Monate auf UTC-Basis. Überzählige Tage werden auf das Monatsende
* geklemmt: der 31. Januar plus einen Monat ist der 28. Februar, nicht der
* 3. März.
*/
function monateAddieren(basis: Date, monate: number): Date {
const jahr = basis.getUTCFullYear();
const monat = basis.getUTCMonth() + monate;
const tag = basis.getUTCDate();
const letzterTagImZielmonat = new Date(Date.UTC(jahr, monat + 1, 0)).getUTCDate();
return new Date(Date.UTC(jahr, monat, Math.min(tag, letzterTagImZielmonat)));
}
/** Addiert Jahre auf UTC-Basis, mit derselben Klemmung am 29. Februar. */
function jahreAddieren(basis: Date, jahre: number): Date {
return monateAddieren(basis, jahre * 12);
}
/**
* Der heutige Kalendertag in Europe/Berlin, als UTC-Mitternacht.
*
* Wichtig für die Stunden um Mitternacht: Um 00:30 Uhr Berliner Zeit ist es
* nach UTC noch der Vortag. Eine Frist, die "heute" abläuft, darf davon nicht
* abhängen.
*/
function heutigerTag(jetzt: Date): Date {
const [jahr, monat, tag] = formatInTimeZone(jetzt, "Europe/Berlin", "yyyy-MM-dd")
.split("-")
.map(Number);
return new Date(Date.UTC(jahr!, monat! - 1, tag!));
}
/** Kalendertage zwischen zwei UTC-Tagen. */
function tagesabstand(von: Date, bis: Date): number {
return Math.round((utcTag(bis).getTime() - utcTag(von).getTime()) / 86_400_000);
}
/** Wie dringend eine Frist ist. */
export type Fristzustand =
| "ueberfaellig"
| "faellig"
| "bald_faellig"
| "in_ordnung"
| "ohne_frist";
export const FRISTZUSTAND_BEZEICHNUNG: Record<Fristzustand, string> = {
ueberfaellig: "überfällig",
faellig: "heute fällig",
bald_faellig: "läuft bald ab",
in_ordnung: "in Ordnung",
ohne_frist: "ohne Frist",
};
/**
* Vorgaben, die vom Gerätetyp kommen und vom einzelnen Gerät überschrieben
* werden dürfen. Der Hersteller einer bestimmten Charge schreibt manchmal
* etwas anderes vor als die Bauart allgemein.
*/
export interface Vorgabe {
pruefintervallMonate?: number | null;
lebensdauerJahre?: number | null;
lebensdauerBezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
vorwarnzeitMonate?: number | null;
}
/** Der jeweils speziellere Wert gewinnt: Gerät vor Typ. */
export function effektiverWert<T>(amGeraet: T | null | undefined, amTyp: T | null | undefined): T | null {
if (amGeraet !== null && amGeraet !== undefined) return amGeraet;
if (amTyp !== null && amTyp !== undefined) return amTyp;
return null;
}
/**
* Nächster Prüftermin.
*
* Gerechnet wird ab der letzten Prüfung, ersatzweise ab der Indienststellung.
* Ohne beides und ohne Intervall gibt es keinen Termin — das ist kein Fehler,
* sondern der Normalfall für Geräte ohne Prüfpflicht.
*/
export function naechstePruefung(angaben: {
letztePruefung?: Date | null;
indienststellung?: Date | null;
intervallMonate?: number | null;
}): Date | null {
const { letztePruefung, indienststellung, intervallMonate } = angaben;
if (!intervallMonate || intervallMonate <= 0) return null;
const basis = letztePruefung ?? indienststellung;
if (!basis) return null;
return monateAddieren(utcTag(basis), intervallMonate);
}
/**
* Ende der Nutzungsdauer.
*
* Helme, Seile, Pressluftatmer und Schutzkleidung sind unabhängig vom Zustand
* nur eine begrenzte Zeit zugelassen. Woran sich das bemisst, ist je nach
* Vorschrift unterschiedlich: bei Seilen ab Herstellung, bei Schutzkleidung
* meist ab Indienststellung.
*/
export function lebensdauerEnde(angaben: {
herstellungsdatum?: Date | null;
indienststellung?: Date | null;
lebensdauerJahre?: number | null;
bezug?: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
}): Date | null {
const { herstellungsdatum, indienststellung, lebensdauerJahre, bezug } = angaben;
if (!lebensdauerJahre || lebensdauerJahre <= 0) return null;
const basis =
bezug === "HERSTELLUNG"
? (herstellungsdatum ?? indienststellung)
: (indienststellung ?? herstellungsdatum);
if (!basis) return null;
return jahreAddieren(utcTag(basis), lebensdauerJahre);
}
/**
* Wie es um eine Frist steht.
*
* @param frist Stichtag, oder null wenn es keinen gibt
* @param vorwarnTage ab wann gewarnt wird
* @param heute überschreibbar, damit Tests nicht von der Uhr abhängen
*/
export function fristzustand(
frist: Date | null | undefined,
vorwarnTage = 30,
heute: Date = new Date(),
): Fristzustand {
if (!frist) return "ohne_frist";
const tage = tagesabstand(heutigerTag(heute), frist);
if (tage < 0) return "ueberfaellig";
if (tage === 0) return "faellig";
if (tage <= vorwarnTage) return "bald_faellig";
return "in_ordnung";
}
/** Verbleibende Kalendertage bis zur Frist. Negativ, wenn sie vorbei ist. */
export function tageBis(frist: Date | null | undefined, heute: Date = new Date()): number | null {
if (!frist) return null;
return tagesabstand(heutigerTag(heute), frist);
}
/**
* Welchen Status ein Gerät haben müsste.
*
* Vorsatz dabei: die Berechnung entscheidet nur über den Übergang
* einsatzbereit ⇄ pruefung_faellig. Wer ein Gerät gesperrt oder ausgesondert
* hat, hat das bewusst getan — das übersteuert keine Fristenrechnung.
*/
export function abgeleiteterGeraetStatus(angaben: {
aktuellerStatus: "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR";
naechstePruefung?: Date | null;
lebensdauerBis?: Date | null;
heute?: Date;
}): "EINSATZBEREIT" | "PRUEFUNG_FAELLIG" | "GESPERRT" | "AUSGESONDERT" | "IN_REPARATUR" {
const { aktuellerStatus, naechstePruefung: pruefung, lebensdauerBis, heute = new Date() } = angaben;
// Von Hand gesetzte Zustände bleiben, wie sie sind.
if (
aktuellerStatus === "GESPERRT" ||
aktuellerStatus === "AUSGESONDERT" ||
aktuellerStatus === "IN_REPARATUR"
) {
return aktuellerStatus;
}
// Abgelaufene Lebensdauer wiegt schwerer als eine fällige Prüfung: das Gerät
// darf nicht mehr eingesetzt werden, auch wenn es äußerlich in Ordnung ist.
if (lebensdauerBis && fristzustand(lebensdauerBis, 0, heute) === "ueberfaellig") {
return "GESPERRT";
}
const zustand = fristzustand(pruefung, 0, heute);
if (zustand === "ueberfaellig" || zustand === "faellig") return "PRUEFUNG_FAELLIG";
return "EINSATZBEREIT";
}
/**
* Ablaufdatum einer Qualifikation.
* Ohne Gültigkeitsdauer gilt sie unbefristet — dann gibt es kein Ablaufdatum.
*/
export function qualifikationGueltigBis(
erworbenAm: Date,
gueltigkeitMonate: number | null | undefined,
): Date | null {
if (!gueltigkeitMonate || gueltigkeitMonate <= 0) return null;
return monateAddieren(utcTag(erworbenAm), gueltigkeitMonate);
}
/** Zustand einer Qualifikation, mit der Vorwarnzeit ihres Typs. */
export function qualifikationZustand(
gueltigBis: Date | null | undefined,
vorwarnzeitTage = 90,
heute: Date = new Date(),
): Fristzustand {
return fristzustand(gueltigBis, vorwarnzeitTage, heute);
}
/**
* Zeitfenster für das Fristen-Dashboard ab Phase 2. Steht hier, weil die
* Grenzen schon in Phase 1 für die Ablaufwarnung gebraucht werden.
*/
export function fristFenster(heute: Date = new Date()) {
const start = heutigerTag(heute);
const plusTage = (tage: number) => new Date(start.getTime() + tage * 86_400_000);
return {
heute: start,
inSiebenTagen: plusTage(7),
inDreissigTagen: plusTage(30),
inNeunzigTagen: plusTage(90),
};
}
+602
View File
@@ -0,0 +1,602 @@
/**
* Feldbeschreibungen für den Import.
*
* Bewusst frei von Datei- und Datenbankzugriff: Was ein Wert bedeutet und wann
* er gültig ist, lässt sich so ohne laufenden Stack durchtesten. Das Einlesen
* der Datei steht in import.ts.
*
* Jede Wehr bringt eine andere Tabelle mit. Deshalb hat jedes Feld eine Liste
* gebräuchlicher Spaltenüberschriften — damit die Zuordnung meistens schon
* stimmt, bevor jemand etwas anklickt.
*/
export type FeldTyp = "text" | "zahl" | "dezimal" | "datum" | "auswahl" | "jaNein";
export interface ImportFeld {
/** Feldname im Datenmodell. */
schluessel: string;
bezeichnung: string;
typ: FeldTyp;
pflicht: boolean;
/** Überschriften, unter denen dieses Feld erfahrungsgemäß auftaucht. */
aliase: string[];
/** Erlaubte Werte bei typ "auswahl", jeweils Anzeige -> gespeicherter Wert. */
auswahl?: Record<string, string>;
hinweis?: string;
maxLaenge?: number;
}
/** Vergleichsform einer Überschrift: ohne Umlaute, Sonderzeichen, Groß/Klein. */
export function normalisiereUeberschrift(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, "ae")
.replace(/ö/g, "oe")
.replace(/ü/g, "ue")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9]/g, "");
}
// -----------------------------------------------------------------------------
// Wertumwandlung
// -----------------------------------------------------------------------------
export interface WertErgebnis<T = unknown> {
ok: boolean;
wert?: T;
fehler?: string;
}
/**
* Datum aus dem, was in Tabellen tatsächlich steht.
*
* Abgedeckt: 02.09.2026, 2.9.2026, 02.09.26, 2026-09-02, 02/09/2026 und die
* Seriennummer, die Excel intern verwendet. Bei zweistelligen Jahren gilt die
* übliche Grenze: 70 und darüber ist 19xx, darunter 20xx.
*/
export function datumLesen(roh: string): WertErgebnis<Date> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
// Excel-Seriennummer (Tage seit dem 30.12.1899).
if (/^\d{5}$/.test(text)) {
const tage = Number(text);
const datum = new Date(Date.UTC(1899, 11, 30));
datum.setUTCDate(datum.getUTCDate() + tage);
return { ok: true, wert: datum };
}
// ISO: 2026-09-02
const iso = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (iso) {
return bauDatum(Number(iso[1]), Number(iso[2]), Number(iso[3]));
}
// Deutsch: 02.09.2026 oder 2.9.26, auch mit / oder -
const deutsch = text.match(/^(\d{1,2})[./-](\d{1,2})[./-](\d{2}|\d{4})$/);
if (deutsch) {
let jahr = Number(deutsch[3]);
if (jahr < 100) jahr += jahr >= 70 ? 1900 : 2000;
return bauDatum(jahr, Number(deutsch[2]), Number(deutsch[1]));
}
return { ok: false, fehler: `"${roh}" ist kein erkennbares Datum (erwartet: TT.MM.JJJJ)` };
}
function bauDatum(jahr: number, monat: number, tag: number): WertErgebnis<Date> {
if (monat < 1 || monat > 12) return { ok: false, fehler: `Monat ${monat} gibt es nicht` };
if (tag < 1 || tag > 31) return { ok: false, fehler: `Tag ${tag} gibt es nicht` };
const datum = new Date(Date.UTC(jahr, monat - 1, tag, 12, 0, 0));
// Rollt der 31.02. auf den 03.03. weiter, war die Eingabe falsch.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) {
return { ok: false, fehler: `Den ${tag}.${monat}.${jahr} gibt es nicht` };
}
if (jahr < 1900 || jahr > 2100) {
return { ok: false, fehler: `Das Jahr ${jahr} ist unplausibel` };
}
return { ok: true, wert: datum };
}
/** Ganze Zahl. Punkt als Tausendertrenner wird entfernt. */
export function zahlLesen(roh: string): WertErgebnis<number> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const bereinigt = text.replace(/\./g, "").replace(/\s/g, "");
if (!/^-?\d+$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine ganze Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Dezimalzahl in deutscher oder englischer Schreibweise. */
export function dezimalLesen(roh: string): WertErgebnis<number> {
const text = roh.trim().replace(/[€\s]/g, "");
if (!text) return { ok: true, wert: undefined };
// 1.234,56 -> 1234.56 | 1,234.56 -> 1234.56 | 1234,56 -> 1234.56
let bereinigt = text;
const hatKomma = text.includes(",");
const hatPunkt = text.includes(".");
if (hatKomma && hatPunkt) {
bereinigt =
text.lastIndexOf(",") > text.lastIndexOf(".")
? text.replace(/\./g, "").replace(",", ".")
: text.replace(/,/g, "");
} else if (hatKomma) {
bereinigt = text.replace(",", ".");
}
if (!/^-?\d+(\.\d+)?$/.test(bereinigt)) {
return { ok: false, fehler: `"${roh}" ist keine Zahl` };
}
return { ok: true, wert: Number(bereinigt) };
}
/** Wahrheitswert aus den Schreibweisen, die in Tabellen vorkommen. */
export function jaNeinLesen(roh: string): WertErgebnis<boolean> {
const text = roh.trim().toLowerCase();
if (!text) return { ok: true, wert: undefined };
if (["ja", "j", "wahr", "true", "x", "1", "yes", "y"].includes(text)) {
return { ok: true, wert: true };
}
if (["nein", "n", "falsch", "false", "0", "no", "-"].includes(text)) {
return { ok: true, wert: false };
}
return { ok: false, fehler: `"${roh}" ist weder ja noch nein` };
}
/** Auswahlwert, tolerant gegenüber Schreibweise. */
export function auswahlLesen(roh: string, auswahl: Record<string, string>): WertErgebnis<string> {
const text = roh.trim();
if (!text) return { ok: true, wert: undefined };
const gesucht = normalisiereUeberschrift(text);
for (const [anzeige, wert] of Object.entries(auswahl)) {
if (normalisiereUeberschrift(anzeige) === gesucht) return { ok: true, wert };
if (normalisiereUeberschrift(wert) === gesucht) return { ok: true, wert };
}
return {
ok: false,
fehler: `"${roh}" ist kein gültiger Wert. Erlaubt: ${Object.keys(auswahl).join(", ")}`,
};
}
/** Wandelt einen Rohwert nach der Feldbeschreibung um. */
export function wertLesen(feld: ImportFeld, roh: string): WertErgebnis {
const text = (roh ?? "").trim();
if (!text) {
return feld.pflicht
? { ok: false, fehler: `${feld.bezeichnung} ist ein Pflichtfeld` }
: { ok: true, wert: undefined };
}
switch (feld.typ) {
case "datum":
return datumLesen(text);
case "zahl":
return zahlLesen(text);
case "dezimal":
return dezimalLesen(text);
case "jaNein":
return jaNeinLesen(text);
case "auswahl":
return auswahlLesen(text, feld.auswahl ?? {});
case "text":
default:
if (feld.maxLaenge && text.length > feld.maxLaenge) {
return {
ok: false,
fehler: `${feld.bezeichnung} ist zu lang (${text.length} von höchstens ${feld.maxLaenge} Zeichen)`,
};
}
return { ok: true, wert: text };
}
}
// -----------------------------------------------------------------------------
// Felder je Datenart
// -----------------------------------------------------------------------------
const GERAETE_STATUS = {
einsatzbereit: "EINSATZBEREIT",
"Prüfung fällig": "PRUEFUNG_FAELLIG",
gesperrt: "GESPERRT",
ausgesondert: "AUSGESONDERT",
"in Reparatur": "IN_REPARATUR",
};
export const GERAET_FELDER: ImportFeld[] = [
{
schluessel: "inventarnummer",
bezeichnung: "Inventarnummer",
typ: "text",
pflicht: true,
maxLaenge: 60,
aliase: ["inventarnummer", "invnr", "inventarnr", "nummer", "nr", "invnummer", "barcode"],
hinweis: "Muss innerhalb der Wehr eindeutig sein.",
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 160,
aliase: ["bezeichnung", "name", "geraet", "geraetebezeichnung", "artikel", "beschreibung"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fabrikat", "marke", "lieferant"],
},
{
schluessel: "modell",
bezeichnung: "Modell",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["modell", "typ", "typbezeichnung", "ausfuehrung"],
},
{
schluessel: "seriennummer",
bezeichnung: "Seriennummer",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["seriennummer", "seriennr", "sn", "fabriknummer", "geraetenummer"],
},
{
schluessel: "herstellungsdatum",
bezeichnung: "Herstellungsdatum",
typ: "datum",
pflicht: false,
aliase: ["herstellungsdatum", "baujahr", "herstelldatum", "produktionsdatum", "fertigung"],
},
{
schluessel: "indienststellung",
bezeichnung: "Indienststellung",
typ: "datum",
pflicht: false,
aliase: ["indienststellung", "indienst", "anschaffung", "anschaffungsdatum", "kaufdatum", "beschaffung"],
},
{
schluessel: "anschaffungspreis",
bezeichnung: "Anschaffungspreis",
typ: "dezimal",
pflicht: false,
aliase: ["anschaffungspreis", "preis", "kosten", "betrag", "wert", "kaufpreis"],
},
{
schluessel: "status",
bezeichnung: "Status",
typ: "auswahl",
pflicht: false,
auswahl: GERAETE_STATUS,
aliase: ["status", "zustand", "einsatzbereit", "verfuegbarkeit"],
},
{
schluessel: "pruefintervallMonate",
bezeichnung: "Prüfintervall (Monate)",
typ: "zahl",
pflicht: false,
aliase: ["pruefintervall", "intervall", "pruefintervallmonate", "pruefungalle", "wartungsintervall"],
},
{
schluessel: "letztePruefung",
bezeichnung: "Letzte Prüfung",
typ: "datum",
pflicht: false,
aliase: ["letztepruefung", "letzteprufung", "gepruefjtam", "pruefdatum", "letztewartung"],
},
{
schluessel: "naechstePruefung",
bezeichnung: "Nächste Prüfung",
typ: "datum",
pflicht: false,
aliase: ["naechstepruefung", "faelligam", "faellig", "naechstewartung", "pruefungfaellig"],
},
{
schluessel: "lebensdauerJahre",
bezeichnung: "Lebensdauer (Jahre)",
typ: "zahl",
pflicht: false,
aliase: ["lebensdauer", "lebensdauerjahre", "nutzungsdauer", "maxalter"],
},
{
schluessel: "standort",
bezeichnung: "Standort",
typ: "text",
pflicht: false,
maxLaenge: 160,
aliase: ["standort", "ort", "lagerort", "geraetehaus", "raum"],
hinweis: "Wird über den Namen zugeordnet. Unbekannte Standorte werden angelegt.",
},
{
schluessel: "fahrzeug",
bezeichnung: "Fahrzeug",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["fahrzeug", "funkrufname", "verlastung", "verlastetauf"],
hinweis: "Funkrufname oder Kennzeichen. Wird nur zugeordnet, wenn das Fahrzeug existiert.",
},
{
schluessel: "geraeteraum",
bezeichnung: "Geräteraum",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["geraeteraum", "raumamfahrzeug", "fach", "g", "entnahmestelle"],
},
{
schluessel: "bemerkung",
bezeichnung: "Bemerkung",
typ: "text",
pflicht: false,
maxLaenge: 1000,
aliase: ["bemerkung", "notiz", "kommentar", "anmerkung", "hinweis"],
},
];
export const PERSON_FELDER: ImportFeld[] = [
{
schluessel: "nachname",
bezeichnung: "Nachname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["nachname", "name", "familienname", "zuname"],
},
{
schluessel: "vorname",
bezeichnung: "Vorname",
typ: "text",
pflicht: true,
maxLaenge: 100,
aliase: ["vorname", "rufname"],
},
{
schluessel: "mitgliedsnummer",
bezeichnung: "Mitgliedsnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["mitgliedsnummer", "mitgliednr", "nummer", "personalnummer", "nr"],
},
{
schluessel: "geburtsdatum",
bezeichnung: "Geburtsdatum",
typ: "datum",
pflicht: false,
aliase: ["geburtsdatum", "geboren", "gebdatum", "geburtstag"],
},
{
schluessel: "email",
bezeichnung: "E-Mail",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["email", "mail", "emailadresse", "epost"],
},
{
schluessel: "telefon",
bezeichnung: "Telefon",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["telefon", "tel", "festnetz", "telefonnummer"],
},
{
schluessel: "mobil",
bezeichnung: "Mobil",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["mobil", "handy", "mobilnummer", "handynummer"],
},
{
schluessel: "strasse",
bezeichnung: "Straße",
typ: "text",
pflicht: false,
maxLaenge: 200,
aliase: ["strasse", "adresse", "anschrift", "strassehausnummer"],
},
{
schluessel: "plz",
bezeichnung: "PLZ",
typ: "text",
pflicht: false,
maxLaenge: 10,
aliase: ["plz", "postleitzahl"],
},
{
schluessel: "ort",
bezeichnung: "Ort",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["ort", "wohnort", "stadt", "gemeinde"],
},
{
schluessel: "eintrittsdatum",
bezeichnung: "Eintrittsdatum",
typ: "datum",
pflicht: false,
aliase: ["eintrittsdatum", "eintritt", "beitritt", "dabeiseit", "mitgliedseit"],
},
{
schluessel: "abteilung",
bezeichnung: "Abteilung",
typ: "text",
pflicht: false,
maxLaenge: 60,
aliase: ["abteilung", "gruppe", "status", "zugehoerigkeit"],
hinweis: "Zum Beispiel: Aktive, Jugendfeuerwehr, Passive, Ehrenmitglieder.",
},
];
export const FAHRZEUG_FELDER: ImportFeld[] = [
{
schluessel: "funkrufname",
bezeichnung: "Funkrufname",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["funkrufname", "funkruf", "rufname", "florian"],
},
{
schluessel: "bezeichnung",
bezeichnung: "Bezeichnung",
typ: "text",
pflicht: true,
maxLaenge: 120,
aliase: ["bezeichnung", "name", "fahrzeug", "fahrzeugbezeichnung"],
},
{
schluessel: "dinTyp",
bezeichnung: "Typ nach DIN",
typ: "text",
pflicht: false,
maxLaenge: 40,
aliase: ["dintyp", "typ", "fahrzeugtyp", "din", "kurzbezeichnung"],
},
{
schluessel: "kennzeichen",
bezeichnung: "Kennzeichen",
typ: "text",
pflicht: false,
maxLaenge: 20,
aliase: ["kennzeichen", "kfzkennzeichen", "amtlicheskennzeichen", "nummernschild"],
},
{
schluessel: "hersteller",
bezeichnung: "Hersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["hersteller", "fahrgestell", "marke", "fabrikat"],
},
{
schluessel: "aufbauhersteller",
bezeichnung: "Aufbauhersteller",
typ: "text",
pflicht: false,
maxLaenge: 120,
aliase: ["aufbauhersteller", "aufbau", "ausbau"],
},
{
schluessel: "fahrgestellnummer",
bezeichnung: "Fahrgestellnummer",
typ: "text",
pflicht: false,
maxLaenge: 30,
aliase: ["fahrgestellnummer", "fin", "vin", "fahrgestellnr"],
},
{
schluessel: "baujahr",
bezeichnung: "Baujahr",
typ: "zahl",
pflicht: false,
aliase: ["baujahr", "jahr", "bj"],
},
{
schluessel: "erstzulassung",
bezeichnung: "Erstzulassung",
typ: "datum",
pflicht: false,
aliase: ["erstzulassung", "ez", "zulassung"],
},
{
schluessel: "tuevBis",
bezeichnung: "TÜV bis",
typ: "datum",
pflicht: false,
aliase: ["tuevbis", "tuev", "hu", "hauptuntersuchung", "naechstertuev"],
},
{
schluessel: "sitzplaetze",
bezeichnung: "Sitzplätze",
typ: "zahl",
pflicht: false,
aliase: ["sitzplaetze", "besatzung", "plaetze", "sitze"],
},
{
schluessel: "tankinhalt",
bezeichnung: "Tankinhalt (Liter)",
typ: "zahl",
pflicht: false,
aliase: ["tankinhalt", "loeschwasser", "wassertank", "tank"],
},
];
export const IMPORT_ARTEN = {
geraet: { bezeichnung: "Geräte", felder: GERAET_FELDER },
person: { bezeichnung: "Mitglieder", felder: PERSON_FELDER },
fahrzeug: { bezeichnung: "Fahrzeuge", felder: FAHRZEUG_FELDER },
} as const;
export type ImportArt = keyof typeof IMPORT_ARTEN;
/**
* Schlägt für jede Spalte der Datei ein Zielfeld vor.
*
* Zuerst über die Aliasliste, dann über einen Teilstringvergleich. Was nicht
* zugeordnet werden kann, bleibt leer — die Zuordnung wird ohnehin angezeigt
* und lässt sich von Hand ändern, bevor irgendetwas geschrieben wird.
*/
export function spaltenZuordnen(
spalten: string[],
felder: ImportFeld[],
): Record<string, string | null> {
const zuordnung: Record<string, string | null> = {};
const vergeben = new Set<string>();
// Erste Runde: genaue Treffer über die Aliase.
for (const spalte of spalten) {
const normal = normalisiereUeberschrift(spalte);
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
(normalisiereUeberschrift(feld.schluessel) === normal ||
feld.aliase.some((alias) => normalisiereUeberschrift(alias) === normal)),
);
if (treffer) {
zuordnung[spalte] = treffer.schluessel;
vergeben.add(treffer.schluessel);
}
}
// Zweite Runde: Teiltreffer für alles, was übrig ist.
for (const spalte of spalten) {
if (zuordnung[spalte]) continue;
const normal = normalisiereUeberschrift(spalte);
if (!normal) {
zuordnung[spalte] = null;
continue;
}
const treffer = felder.find(
(feld) =>
!vergeben.has(feld.schluessel) &&
feld.aliase.some((alias) => {
const a = normalisiereUeberschrift(alias);
return a.length >= 4 && (normal.includes(a) || a.includes(normal));
}),
);
zuordnung[spalte] = treffer?.schluessel ?? null;
if (treffer) vergeben.add(treffer.schluessel);
}
return zuordnung;
}
+344
View File
@@ -0,0 +1,344 @@
import "server-only";
import Papa from "papaparse";
import ExcelJS from "exceljs";
import {
IMPORT_ARTEN,
spaltenZuordnen,
wertLesen,
type ImportArt,
type ImportFeld,
} from "@/lib/stammdaten/import-felder";
/**
* Datei-Import für Stammdaten.
*
* Ablauf in drei Schritten, und der mittlere ist der wichtige:
*
* 1. Datei einlesen — CSV oder Excel, Spaltenüberschriften erkennen
* 2. Zuordnung und Vorschau — was landet wo, und was stimmt daran nicht
* 3. Übernehmen — erst hier wird geschrieben
*
* Zwischen 2 und 3 sitzt ein Mensch. Ohne diese Vorschau würde eine Wehr dem
* Ergebnis nicht trauen, und zu Recht: eine falsch zugeordnete Spalte
* verteilt Seriennummern auf Inventarnummern, und das merkt man erst Wochen
* später am Regal.
*/
/** Höchstwerte, damit ein versehentlich gewähltes 200-MB-Archiv nicht den
* Server beschäftigt. */
export const MAX_DATEIGROESSE = 10 * 1024 * 1024;
export const MAX_ZEILEN = 5_000;
export interface GeleseneTabelle {
spalten: string[];
zeilen: Record<string, string>[];
/** Wie viele Zeilen die Datei hatte, bevor gekürzt wurde. */
zeilenGesamt: number;
gekuerzt: boolean;
}
export interface ZellFehler {
zeile: number;
spalte: string;
feld: string;
meldung: string;
}
export interface VorschauZeile {
/** Zeilennummer in der Datei, 1-basiert ohne Überschrift. */
nummer: number;
werte: Record<string, unknown>;
rohwerte: Record<string, string>;
fehler: ZellFehler[];
/** true, wenn diese Zeile übernommen werden kann. */
uebernehmbar: boolean;
}
export interface ImportVorschau {
art: ImportArt;
felder: ImportFeld[];
spalten: string[];
zuordnung: Record<string, string | null>;
zeilen: VorschauZeile[];
zeilenGesamt: number;
gekuerzt: boolean;
/** Pflichtfelder, denen keine Spalte zugeordnet ist. */
fehlendePflichtfelder: string[];
anzahlUebernehmbar: number;
anzahlFehlerhaft: number;
}
// -----------------------------------------------------------------------------
// Schritt 1: Datei einlesen
// -----------------------------------------------------------------------------
/** Erkennt das Format an der Endung, ersatzweise am Inhalt. */
export function formatErkennen(dateiname: string): "csv" | "xlsx" | null {
const endung = dateiname.toLowerCase().split(".").pop();
if (endung === "csv" || endung === "txt") return "csv";
if (endung === "xlsx" || endung === "xlsm") return "xlsx";
return null;
}
/**
* CSV einlesen. Das Trennzeichen wird erraten — deutsche Tabellen kommen
* meistens mit Semikolon, weil Excel das im deutschen Sprachraum so schreibt.
*/
export function csvLesen(inhalt: string): GeleseneTabelle {
const ergebnis = Papa.parse<Record<string, string>>(inhalt, {
header: true,
skipEmptyLines: "greedy",
delimiter: "",
delimitersToGuess: [";", ",", "\t", "|"],
transformHeader: (kopf) => kopf.trim(),
});
const alleZeilen = (ergebnis.data ?? []).filter((zeile) =>
Object.values(zeile).some((wert) => (wert ?? "").toString().trim() !== ""),
);
const spalten = (ergebnis.meta.fields ?? []).filter((feld) => feld.trim() !== "");
return {
spalten,
zeilen: alleZeilen.slice(0, MAX_ZEILEN).map(zeileNormalisieren),
zeilenGesamt: alleZeilen.length,
gekuerzt: alleZeilen.length > MAX_ZEILEN,
};
}
/** Excel einlesen. Es wird immer das erste Arbeitsblatt genommen. */
export async function excelLesen(daten: Buffer): Promise<GeleseneTabelle> {
const mappe = new ExcelJS.Workbook();
await mappe.xlsx.load(daten as unknown as ArrayBuffer);
const blatt = mappe.worksheets[0];
if (!blatt) {
return { spalten: [], zeilen: [], zeilenGesamt: 0, gekuerzt: false };
}
const kopfzeile = blatt.getRow(1);
const spalten: string[] = [];
kopfzeile.eachCell({ includeEmpty: false }, (zelle, nummer) => {
const text = zellenText(zelle.value).trim();
spalten[nummer - 1] = text || `Spalte ${nummer}`;
});
const zeilen: Record<string, string>[] = [];
let gesamt = 0;
blatt.eachRow({ includeEmpty: false }, (zeile, nummer) => {
if (nummer === 1) return;
const datensatz: Record<string, string> = {};
let hatInhalt = false;
spalten.forEach((spalte, index) => {
if (!spalte) return;
const wert = zellenText(zeile.getCell(index + 1).value).trim();
datensatz[spalte] = wert;
if (wert) hatInhalt = true;
});
if (!hatInhalt) return;
gesamt += 1;
if (zeilen.length < MAX_ZEILEN) zeilen.push(datensatz);
});
return {
spalten: spalten.filter(Boolean),
zeilen,
zeilenGesamt: gesamt,
gekuerzt: gesamt > MAX_ZEILEN,
};
}
/** Excel liefert je nach Zelle Zahlen, Daten, Formelergebnisse oder Rich Text. */
function zellenText(wert: ExcelJS.CellValue): string {
if (wert === null || wert === undefined) return "";
if (wert instanceof Date) {
const tag = String(wert.getUTCDate()).padStart(2, "0");
const monat = String(wert.getUTCMonth() + 1).padStart(2, "0");
return `${tag}.${monat}.${wert.getUTCFullYear()}`;
}
if (typeof wert === "object") {
if ("text" in wert && typeof wert.text === "string") return wert.text;
if ("result" in wert) return zellenText(wert.result as ExcelJS.CellValue);
if ("richText" in wert && Array.isArray(wert.richText)) {
return wert.richText.map((teil) => teil.text).join("");
}
if ("hyperlink" in wert && typeof wert.text === "string") return wert.text;
return "";
}
return String(wert);
}
function zeileNormalisieren(zeile: Record<string, unknown>): Record<string, string> {
const ergebnis: Record<string, string> = {};
for (const [schluessel, wert] of Object.entries(zeile)) {
ergebnis[schluessel.trim()] = (wert ?? "").toString().trim();
}
return ergebnis;
}
/** Liest eine hochgeladene Datei, unabhängig vom Format. */
export async function tabelleLesen(
daten: Buffer,
dateiname: string,
): Promise<GeleseneTabelle> {
if (daten.length > MAX_DATEIGROESSE) {
throw new Error(
`Die Datei ist zu groß (${Math.round(daten.length / 1024 / 1024)} MB, erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB).`,
);
}
const format = formatErkennen(dateiname);
if (format === "xlsx") return excelLesen(daten);
if (format === "csv") return csvLesen(textAusPuffer(daten));
throw new Error(
`Das Format von "${dateiname}" wird nicht unterstützt. Erlaubt sind CSV und Excel (.xlsx).`,
);
}
/**
* Text aus dem Puffer. Deutsche Exporte kommen oft noch als Windows-1252 —
* eine BOM oder ungültige UTF-8-Folgen verraten das.
*/
export function textAusPuffer(daten: Buffer): string {
// UTF-8 mit BOM
if (daten.length >= 3 && daten[0] === 0xef && daten[1] === 0xbb && daten[2] === 0xbf) {
return daten.subarray(3).toString("utf8");
}
const alsUtf8 = daten.toString("utf8");
// Das Ersetzungszeichen zeigt an, dass es kein gültiges UTF-8 war.
if (alsUtf8.includes("�")) {
return daten.toString("latin1");
}
return alsUtf8;
}
// -----------------------------------------------------------------------------
// Schritt 2: Zuordnung und Vorschau
// -----------------------------------------------------------------------------
/**
* Erzeugt die Vorschau. Schreibt nichts — das ist der ganze Sinn.
*
* @param zuordnung optional; ohne Angabe wird automatisch zugeordnet
*/
export function vorschauErstellen(
tabelle: GeleseneTabelle,
art: ImportArt,
zuordnung?: Record<string, string | null>,
): ImportVorschau {
const felder = IMPORT_ARTEN[art].felder;
const zuord = zuordnung ?? spaltenZuordnen(tabelle.spalten, felder);
const zugeordneteFelder = new Set(
Object.values(zuord).filter((wert): wert is string => Boolean(wert)),
);
const fehlendePflichtfelder = felder
.filter((feld) => feld.pflicht && !zugeordneteFelder.has(feld.schluessel))
.map((feld) => feld.bezeichnung);
const zeilen: VorschauZeile[] = tabelle.zeilen.map((rohzeile, index) => {
const werte: Record<string, unknown> = {};
const fehler: ZellFehler[] = [];
for (const [spalte, feldSchluessel] of Object.entries(zuord)) {
if (!feldSchluessel) continue;
const feld = felder.find((f) => f.schluessel === feldSchluessel);
if (!feld) continue;
const ergebnis = wertLesen(feld, rohzeile[spalte] ?? "");
if (!ergebnis.ok) {
fehler.push({
zeile: index + 1,
spalte,
feld: feld.bezeichnung,
meldung: ergebnis.fehler ?? "Ungültiger Wert",
});
continue;
}
if (ergebnis.wert !== undefined) werte[feldSchluessel] = ergebnis.wert;
}
// Pflichtfelder, für die es gar keine Spalte gibt, melden wir je Zeile —
// sonst sieht man in der Vorschau nicht, dass die Zeile unbrauchbar ist.
for (const feld of felder) {
if (!feld.pflicht) continue;
if (werte[feld.schluessel] !== undefined) continue;
if (fehler.some((f) => f.feld === feld.bezeichnung)) continue;
fehler.push({
zeile: index + 1,
spalte: "—",
feld: feld.bezeichnung,
meldung: `${feld.bezeichnung} fehlt`,
});
}
return {
nummer: index + 1,
werte,
rohwerte: rohzeile,
fehler,
uebernehmbar: fehler.length === 0,
};
});
// Dubletten innerhalb der Datei aufspüren — die fallen sonst erst beim
// Schreiben auf, und dann ist die halbe Datei schon drin.
markiereDubletten(zeilen, art);
return {
art,
felder,
spalten: tabelle.spalten,
zuordnung: zuord,
zeilen,
zeilenGesamt: tabelle.zeilenGesamt,
gekuerzt: tabelle.gekuerzt,
fehlendePflichtfelder,
anzahlUebernehmbar: zeilen.filter((zeile) => zeile.uebernehmbar).length,
anzahlFehlerhaft: zeilen.filter((zeile) => !zeile.uebernehmbar).length,
};
}
/** Welches Feld eine Zeile innerhalb der Datei eindeutig macht. */
const SCHLUESSELFELD: Record<ImportArt, string> = {
geraet: "inventarnummer",
person: "mitgliedsnummer",
fahrzeug: "funkrufname",
};
function markiereDubletten(zeilen: VorschauZeile[], art: ImportArt): void {
const feld = SCHLUESSELFELD[art];
const gesehen = new Map<string, number>();
for (const zeile of zeilen) {
const wert = zeile.werte[feld];
if (typeof wert !== "string" || !wert) continue;
const schluessel = wert.toLowerCase();
const zuerst = gesehen.get(schluessel);
if (zuerst !== undefined) {
zeile.fehler.push({
zeile: zeile.nummer,
spalte: feld,
feld,
meldung: `Kommt in dieser Datei schon in Zeile ${zuerst} vor`,
});
zeile.uebernehmbar = false;
} else {
gesehen.set(schluessel, zeile.nummer);
}
}
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Auswertung gescannter Codes.
*
* Bewusst ohne "server-only" und ohne Abhängigkeiten: Diese Funktion läuft im
* Browser, direkt am Kamerabild. Die Erzeugung von QR-Codes steht dagegen in
* qr.ts und bleibt auf dem Server.
*/
/**
* Liest die Inventarnummer aus dem, was ein Scanner geliefert hat.
*
* Akzeptiert wird alles, was plausibel ist: die volle URL aus unserem eigenen
* Code, ein Pfad, oder die blanke Inventarnummer — manche Wehr hat schon
* Etiketten mit reinen Nummern im Bestand, und die sollen weiter funktionieren.
* Fremde Codes (etwa der Hersteller-Barcode auf dem Gerät) geben null zurück.
*/
export function inventarnummerAusScan(eingabe: string): string | null {
const text = eingabe.trim();
if (!text) return null;
// Volle URL oder Pfad auf /g/<nr>
const treffer = text.match(/\/g\/([^/?#\s]+)/i);
if (treffer?.[1]) {
try {
return decodeURIComponent(treffer[1]);
} catch {
return treffer[1];
}
}
// Eine fremde URL ist keine Inventarnummer.
if (/^https?:\/\//i.test(text)) return null;
// Blanke Nummer: Buchstaben, Ziffern, Bindestrich, Schrägstrich, Punkt.
if (/^[A-Za-z0-9][A-Za-z0-9._/-]{0,59}$/.test(text)) return text;
return null;
}
+64
View File
@@ -0,0 +1,64 @@
import "server-only";
import QRCode from "qrcode";
import { env } from "@/lib/env";
/**
* QR-Codes für Geräte.
*
* Der Code enthält eine kurze URL auf die eigene Installation:
*
* https://<florian>/g/<inventarnummer>
*
* Bewusst die Inventarnummer und nicht die UUID: Die Nummer steht ohnehin
* lesbar auf dem Etikett, ist kurz genug für einen groben Code mit dicken
* Modulen, und wer den Code mit einem beliebigen Scanner liest, sieht sofort,
* um welches Gerät es geht. Eine UUID wäre 36 Zeichen lang und für niemanden
* lesbar.
*
* Die URL ist keine Berechtigung: /g/<nr> leitet nur weiter, und die Zielseite
* verlangt eine Anmeldung wie jede andere auch. Wer den Aufkleber fotografiert,
* bekommt dadurch keinen Zugang.
*/
/** Fehlerkorrektur. "M" verträgt rund 15 % Verlust — genug für ein Etikett,
* das im Geräteraum Staub und Schrammen abbekommt. */
const FEHLERKORREKTUR = "M" as const;
export function qrZiel(inventarnummer: string): string {
const basis = env.APP_URL.replace(/\/+$/, "");
return `${basis}/g/${encodeURIComponent(inventarnummer)}`;
}
/**
* QR-Code als SVG. SVG statt PNG, weil der Etikettenbogen als PDF gerendert
* wird — dort skaliert eine Vektorgrafik ohne Treppenkanten.
*/
export async function qrAlsSvg(inventarnummer: string, groesseMm = 20): Promise<string> {
const svg = await QRCode.toString(qrZiel(inventarnummer), {
type: "svg",
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 0,
width: Math.round(groesseMm * 8),
});
// qrcode setzt feste Pixelmaße; für das PDF sollen Millimeter gelten.
return svg
.replace(/width="[^"]+"/, `width="${groesseMm}mm"`)
.replace(/height="[^"]+"/, `height="${groesseMm}mm"`);
}
/** QR-Code als Data-URI. Für die Anzeige in der Gerätedetailseite. */
export async function qrAlsDataUri(inventarnummer: string, groessePx = 256): Promise<string> {
return QRCode.toDataURL(qrZiel(inventarnummer), {
errorCorrectionLevel: FEHLERKORREKTUR,
margin: 1,
width: groessePx,
color: { dark: "#000000ff", light: "#ffffffff" },
});
}
/**
* Die Auswertung gescannter Codes steht in qr-parser.ts — sie läuft im
* Browser und darf deshalb nicht in einem "server-only"-Modul liegen.
*/
export { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers/_app";
/**
* tRPC im Browser.
*
* Über dieses Objekt laufen alle Aufrufe aus Client-Komponenten. Die Typen
* kommen direkt aus dem Server-Router — ändert sich dort ein Feld, meckert
* hier der Compiler, und zwar bevor jemand die Seite öffnet.
*/
export const trpc = createTRPCReact<AppRouter>();
+59
View File
@@ -0,0 +1,59 @@
"use client";
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import superjson from "superjson";
import { trpc } from "@/lib/trpc/client";
/**
* Hängt tRPC und React Query in den Baum.
*
* Die Einstellungen sind auf eine Verwaltungsanwendung zugeschnitten: Daten
* bleiben eine halbe Minute frisch, und beim Fensterwechsel wird nicht neu
* geladen. Wer eine Geräteliste offen hat und kurz ins Mailprogramm wechselt,
* soll nicht bei jeder Rückkehr ein Nachladen sehen.
*
* Ausnahme wird ab Phase 5 die Atemschutzüberwachung — dort zählt jede
* Sekunde, und die Ansicht bekommt eigene Einstellungen.
*/
export function TrpcProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: false,
retry: (versuche, fehler) => {
// Bei fehlender Berechtigung oder unbekanntem Datensatz bringt
// ein zweiter Versuch nichts.
const code = (fehler as { data?: { code?: string } })?.data?.code;
if (code === "FORBIDDEN" || code === "UNAUTHORIZED" || code === "NOT_FOUND") {
return false;
}
return versuche < 2;
},
},
},
}),
);
const [trpcClient] = React.useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
maxURLLength: 2000,
}),
],
}),
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
+44 -6
View File
@@ -3,11 +3,18 @@ import { router, oeffentlich, angemeldet, mitRecht } from "@/server/trpc";
import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz"; import { ROLLEN_BEZEICHNUNG, rechteVon } from "@/lib/authz";
import { datenbankErreichbar } from "@/lib/db"; import { datenbankErreichbar } from "@/lib/db";
import { speicherErreichbar } from "@/lib/storage"; import { speicherErreichbar } from "@/lib/storage";
import { geraetRouter } from "@/server/routers/stammdaten/geraet";
import { fahrzeugRouter } from "@/server/routers/stammdaten/fahrzeug";
import { standortRouter, geraeteraumRouter } from "@/server/routers/stammdaten/orte";
import { geraetetypRouter, qualifikationstypRouter } from "@/server/routers/stammdaten/katalog";
import { personRouter, qualifikationRouter } from "@/server/routers/stammdaten/person";
import { importRouter } from "@/server/routers/stammdaten/import";
/** /**
* Wurzel-Router. Ab Phase 1 kommen hier die Fachbereiche als Unter-Router * Wurzel-Router.
* dazu (stammdaten, wartung, atemschutz, einsatz, ausbildung, kasse). *
* Phase 0 liefert nur, was zum Prüfen des Fundaments nötig ist. * Aufbau nach Fachbereichen, nicht nach Tabellen: wer die Geräteliste sucht,
* findet sie unter geraet, nicht unter irgendeinem technischen Namen.
*/ */
const systemRouter = router({ const systemRouter = router({
@@ -68,13 +75,25 @@ const organisationRouter = router({
/** Kennzahlen für die Startseite. */ /** Kennzahlen für die Startseite. */
kennzahlen: angemeldet.query(async ({ ctx }) => kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => { ctx.db(async (db) => {
const [mitglieder, fahrzeuge, geraete, pruefungFaellig] = await Promise.all([ const in90Tagen = new Date(Date.now() + 90 * 864e5);
const [mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, qualiAblauf] =
await Promise.all([
db.person.count({ where: { aktiv: true, geloeschtAm: null } }), db.person.count({ where: { aktiv: true, geloeschtAm: null } }),
db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }), db.fahrzeug.count({ where: { aktiv: true, geloeschtAm: null } }),
db.geraet.count({ where: { geloeschtAm: null } }), db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }), db.geraet.count({ where: { status: "PRUEFUNG_FAELLIG", geloeschtAm: null } }),
db.geraet.count({ where: { status: "GESPERRT", geloeschtAm: null } }),
db.qualifikation.count({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: in90Tagen },
person: { aktiv: true, geloeschtAm: null },
},
}),
]); ]);
return { mitglieder, fahrzeuge, geraete, pruefungFaellig };
return { mitglieder, fahrzeuge, geraete, pruefungFaellig, gesperrt, qualiAblauf };
}), }),
), ),
}); });
@@ -82,10 +101,18 @@ const organisationRouter = router({
const auditRouter = router({ const auditRouter = router({
/** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */ /** Die letzten Einträge im Protokoll — nur mit dem entsprechenden Recht. */
letzte: mitRecht("system.auditlog") letzte: mitRecht("system.auditlog")
.input(z.object({ anzahl: z.number().int().min(1).max(200).default(50) }).optional()) .input(
z
.object({
anzahl: z.number().int().min(1).max(200).default(50),
entitaet: z.string().max(80).optional(),
})
.optional(),
)
.query(async ({ ctx, input }) => .query(async ({ ctx, input }) =>
ctx.db((db) => ctx.db((db) =>
db.auditLog.findMany({ db.auditLog.findMany({
where: input?.entitaet ? { entitaet: input.entitaet } : {},
orderBy: { zeitpunkt: "desc" }, orderBy: { zeitpunkt: "desc" },
take: input?.anzahl ?? 50, take: input?.anzahl ?? 50,
include: { include: {
@@ -101,6 +128,17 @@ export const appRouter = router({
sitzung: sitzungRouter, sitzung: sitzungRouter,
organisation: organisationRouter, organisation: organisationRouter,
audit: auditRouter, audit: auditRouter,
// Phase 1 — Stammdaten
standort: standortRouter,
fahrzeug: fahrzeugRouter,
geraeteraum: geraeteraumRouter,
geraetetyp: geraetetypRouter,
geraet: geraetRouter,
person: personRouter,
qualifikationstyp: qualifikationstypRouter,
qualifikation: qualifikationRouter,
datenImport: importRouter,
}); });
export type AppRouter = typeof appRouter; export type AppRouter = typeof appRouter;
+272
View File
@@ -0,0 +1,272 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { FahrzeugArt, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Fahrzeuge.
*
* Der Funkrufname ist der Schlüssel, unter dem eine Wehr ihr Fahrzeug kennt —
* nicht das Kennzeichen. Danach wird sortiert, gesucht und im Einsatz gerufen.
*/
const SORTIERFELDER = [
"funkrufname",
"bezeichnung",
"dinTyp",
"kennzeichen",
"baujahr",
"tuevBis",
"erstelltAm",
] as const;
const fahrzeugDaten = z.object({
funkrufname: z.string().trim().min(1, "Der Funkrufname fehlt.").max(120),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(120),
art: z.nativeEnum(FahrzeugArt).default(FahrzeugArt.LOESCHFAHRZEUG),
dinTyp: z.string().trim().max(40).nullish(),
kennzeichen: z.string().trim().max(20).nullish(),
hersteller: z.string().trim().max(120).nullish(),
aufbauhersteller: z.string().trim().max(120).nullish(),
fahrgestellnummer: z.string().trim().max(30).nullish(),
baujahr: z.number().int().min(1900).max(2100).nullish(),
erstzulassung: z.coerce.date().nullish(),
indienststellung: z.coerce.date().nullish(),
sitzplaetze: z.number().int().min(1).max(30).nullish(),
tankinhalt: z.number().int().min(0).max(50_000).nullish(),
gesamtmasse: z.number().int().min(0).max(100_000).nullish(),
kilometerstand: z.number().int().min(0).max(2_000_000).nullish(),
tuevBis: z.coerce.date().nullish(),
uvvBis: z.coerce.date().nullish(),
naechsteWartung: z.coerce.date().nullish(),
standortId: z.string().uuid().nullish(),
aktiv: z.boolean().default(true),
bemerkung: z.string().trim().max(1000).nullish(),
});
/** Räume, die bei einem neuen Fahrzeug fast immer gebraucht werden. */
const STANDARDRAEUME = [
{ bezeichnung: "G1", beschreibung: "Geräteraum 1, links vorn", sortierung: 10 },
{ bezeichnung: "G2", beschreibung: "Geräteraum 2, links Mitte", sortierung: 20 },
{ bezeichnung: "G3", beschreibung: "Geräteraum 3, links hinten", sortierung: 30 },
{ bezeichnung: "G4", beschreibung: "Geräteraum 4, rechts vorn", sortierung: 40 },
{ bezeichnung: "G5", beschreibung: "Geräteraum 5, rechts Mitte", sortierung: 50 },
{ bezeichnung: "G6", beschreibung: "Geräteraum 6, rechts hinten", sortierung: 60 },
{ bezeichnung: "GR", beschreibung: "Geräteraum Heck", sortierung: 70 },
{ bezeichnung: "Dach", beschreibung: "Dachkasten", sortierung: 80 },
{ bezeichnung: "MR", beschreibung: "Mannschaftsraum", sortierung: 90 },
];
export const fahrzeugRouter = router({
liste: angemeldet
.input(
listenEingabe(SORTIERFELDER).extend({
art: z.array(z.nativeEnum(FahrzeugArt)).optional(),
standortId: z.string().uuid().optional(),
nurAktive: z.boolean().default(true),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.FahrzeugWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.nurAktive ? { aktiv: true } : {}),
...(input.art?.length ? { art: { in: input.art } } : {}),
...(input.standortId ? { standortId: input.standortId } : {}),
...(sucheUeber(
["funkrufname", "bezeichnung", "kennzeichen", "dinTyp", "hersteller"],
input.suche,
) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.fahrzeug.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
funkrufname: "asc",
}) as Prisma.FahrzeugOrderByWithRelationInput,
include: {
standort: { select: { id: true, name: true } },
_count: { select: { geraete: true, geraeteraeume: true } },
},
...blaettern(input),
}),
db.fahrzeug.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.fahrzeug.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, funkrufname: true, bezeichnung: true, dinTyp: true },
orderBy: { funkrufname: "asc" },
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fahrzeug = await db.fahrzeug.findUnique({
where: { id: input.id },
include: {
standort: { select: { id: true, name: true } },
geraeteraeume: {
where: { geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: { _count: { select: { geraete: true } } },
},
_count: { select: { geraete: true } },
},
});
if (!fahrzeug) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
return fahrzeug;
}),
),
anlegen: mitRecht("fahrzeug.bearbeiten")
.input(
fahrzeugDaten.extend({
/** Legt G1 bis G6, Heck, Dach und Mannschaftsraum gleich mit an. */
standardraeumeAnlegen: z.boolean().default(true),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { standardraeumeAnlegen, ...daten } = input;
const fahrzeug = await db.fahrzeug.create({
data: { organisationId: ctx.sitzung.organisationId, ...daten },
});
if (standardraeumeAnlegen) {
await db.geraeteraum.createMany({
data: STANDARDRAEUME.map((raum) => ({
organisationId: ctx.sitzung.organisationId,
fahrzeugId: fahrzeug.id,
...raum,
})),
});
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Fahrzeug",
entitaetId: fahrzeug.id,
beschreibung: `Fahrzeug ${fahrzeug.funkrufname} angelegt`,
sitzung: ctx.sitzung,
nachher: fahrzeug,
kontext: { standardraeume: standardraeumeAnlegen },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return fahrzeug;
}),
),
aendern: mitRecht("fahrzeug.bearbeiten")
.input(fahrzeugDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.fahrzeug.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
const nachher = await db.fahrzeug.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Fahrzeug",
entitaetId: id,
beschreibung: `Fahrzeug ${nachher.funkrufname} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("fahrzeug.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.fahrzeug.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `Auf diesem Fahrzeug sind noch ${vorher._count.geraete} Geräte verlastet. Bitte zuerst umlagern.`,
});
}
await db.fahrzeug.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Fahrzeug",
entitaetId: input.id,
beschreibung: `Fahrzeug ${vorher.funkrufname} außer Dienst gestellt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
verlauf: angemeldet
.input(idEingabe.extend({ anzahl: z.number().int().min(1).max(200).default(50) }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
where: { entitaet: "Fahrzeug", entitaetId: input.id },
orderBy: { zeitpunkt: "desc" },
take: input.anzahl,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
vorher: true,
nachher: true,
akteurName: true,
},
}),
),
),
});
+105
View File
@@ -0,0 +1,105 @@
import { z } from "zod";
/**
* Bausteine, die alle Stammdatenlisten teilen.
*
* Jede Liste in FLORIAN kann dasselbe: sortieren, filtern, durchsuchen,
* blättern. Damit sich das nicht in jedem Router wiederholt — und vor allem,
* damit es sich überall gleich verhält — steht die Form der Abfrage hier.
*/
/** Höchstzahl je Seite. Mehr als das braucht keine Ansicht, und es schützt
* davor, dass ein Aufruf versehentlich zehntausend Zeilen zieht. */
export const MAX_SEITENGROESSE = 200;
export const sortierrichtung = z.enum(["asc", "desc"]);
/**
* Standardform einer Listenabfrage.
*
* @param sortierfelder erlaubte Sortierspalten — als Aufzählung, damit kein
* beliebiger Feldname in die Abfrage wandern kann
*/
export function listenEingabe<T extends readonly [string, ...string[]]>(sortierfelder: T) {
return z.object({
/** Volltextsuche über die Felder, die der jeweilige Router festlegt. */
suche: z.string().trim().max(200).optional(),
seite: z.number().int().min(1).default(1),
seitengroesse: z.number().int().min(1).max(MAX_SEITENGROESSE).default(50),
sortierung: z.enum(sortierfelder as unknown as [string, ...string[]]).optional(),
richtung: sortierrichtung.default("asc"),
/** Gelöschte mit anzeigen. Nur für die Wiederherstellung gedacht. */
mitGeloeschten: z.boolean().default(false),
});
}
export type ListenEingabe = z.infer<ReturnType<typeof listenEingabe<readonly ["id"]>>>;
/** Antwortform jeder Liste. */
export interface ListenAntwort<T> {
eintraege: T[];
gesamt: number;
seite: number;
seitengroesse: number;
seitenGesamt: number;
}
export function listenAntwort<T>(
eintraege: T[],
gesamt: number,
eingabe: { seite: number; seitengroesse: number },
): ListenAntwort<T> {
return {
eintraege,
gesamt,
seite: eingabe.seite,
seitengroesse: eingabe.seitengroesse,
seitenGesamt: Math.max(1, Math.ceil(gesamt / eingabe.seitengroesse)),
};
}
/** Übersetzt Seite und Seitengröße in skip/take. */
export function blaettern(eingabe: { seite: number; seitengroesse: number }) {
return {
skip: (eingabe.seite - 1) * eingabe.seitengroesse,
take: eingabe.seitengroesse,
};
}
/**
* Baut aus einem Suchbegriff eine ODER-Bedingung über mehrere Textfelder.
* Leere Suche gibt undefined zurück — dann bleibt die Bedingung ganz weg.
*/
export function sucheUeber(felder: string[], begriff?: string) {
const text = begriff?.trim();
if (!text) return undefined;
return {
OR: felder.map((feld) => ({
[feld]: { contains: text, mode: "insensitive" as const },
})),
};
}
/** Sortierung mit einem verlässlichen Standard. */
export function sortierenNach(
feld: string | undefined,
richtung: "asc" | "desc",
standard: Record<string, "asc" | "desc">,
): Record<string, unknown> {
if (!feld) return standard;
return { [feld]: richtung };
}
/** Blendet Soft-Deletes aus, sofern nicht ausdrücklich gewünscht. */
export function ohneGeloeschte(mitGeloeschten: boolean) {
return mitGeloeschten ? {} : { geloeschtAm: null };
}
/** Eingabe für alles, was über eine Kennung angesprochen wird. */
export const idEingabe = z.object({ id: z.string().uuid() });
/** Eingabe für die Massenbearbeitung. */
export const idsEingabe = z.object({
ids: z.array(z.string().uuid()).min(1).max(500),
});
+582
View File
@@ -0,0 +1,582 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { GeraetStatus, LebensdauerKategorie, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
idsEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
import {
abgeleiteterGeraetStatus,
effektiverWert,
lebensdauerEnde,
naechstePruefung,
} from "@/lib/stammdaten/fristen";
/**
* Geräte — das Herzstück der Stammdaten.
*
* Zwei Dinge passieren hier automatisch, damit sie nicht vergessen werden:
* - Prüftermin und Lebensdauerende werden bei jedem Speichern neu gerechnet,
* aus den Angaben am Gerät und, wo diese fehlen, aus dem Gerätetyp.
* - Der Status folgt daraus, solange ihn niemand von Hand auf gesperrt,
* ausgesondert oder in Reparatur gesetzt hat.
*/
const SORTIERFELDER = [
"inventarnummer",
"bezeichnung",
"hersteller",
"status",
"naechstePruefung",
"lebensdauerBis",
"indienststellung",
"erstelltAm",
] as const;
const SUCHFELDER = ["inventarnummer", "bezeichnung", "hersteller", "modell", "seriennummer"];
/** Was in der Liste angezeigt wird — bewusst schmal gehalten. */
const LISTEN_AUSWAHL = {
id: true,
inventarnummer: true,
bezeichnung: true,
hersteller: true,
modell: true,
seriennummer: true,
status: true,
indienststellung: true,
letztePruefung: true,
naechstePruefung: true,
lebensdauerBis: true,
lagerort: true,
geloeschtAm: true,
geraetetyp: { select: { id: true, name: true, kategorie: true } },
standort: { select: { id: true, name: true } },
fahrzeug: { select: { id: true, funkrufname: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
} satisfies Prisma.GeraetSelect;
/**
* Wo ein Gerät liegt. Entweder Standort oder Fahrzeug — die Datenbank setzt
* das ebenfalls durch, aber hier kommt eine verständliche Meldung heraus
* statt eines Constraint-Fehlers.
*/
const lagerung = z
.object({
standortId: z.string().uuid().nullish(),
fahrzeugId: z.string().uuid().nullish(),
geraeteraumId: z.string().uuid().nullish(),
})
.refine(
(wert) => Boolean(wert.standortId) !== Boolean(wert.fahrzeugId),
"Ein Gerät steht entweder an einem Standort oder ist auf einem Fahrzeug verlastet — nicht beides und nicht keines von beidem.",
)
.refine(
(wert) => !wert.geraeteraumId || Boolean(wert.fahrzeugId),
"Ein Geräteraum lässt sich nur zusammen mit einem Fahrzeug angeben.",
);
const stammdaten = z.object({
inventarnummer: z.string().trim().min(1, "Die Inventarnummer fehlt.").max(60),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(160),
geraetetypId: z.string().uuid().nullish(),
hersteller: z.string().trim().max(120).nullish(),
modell: z.string().trim().max(120).nullish(),
seriennummer: z.string().trim().max(120).nullish(),
herstellungsdatum: z.coerce.date().nullish(),
indienststellung: z.coerce.date().nullish(),
anschaffungspreis: z.number().nonnegative().max(99_999_999).nullish(),
status: z.nativeEnum(GeraetStatus).default(GeraetStatus.EINSATZBEREIT),
pruefintervallMonate: z.number().int().min(1).max(600).nullish(),
letztePruefung: z.coerce.date().nullish(),
lebensdauerKategorie: z.nativeEnum(LebensdauerKategorie).nullish(),
lebensdauerJahre: z.number().int().min(1).max(100).nullish(),
lagerort: z.string().trim().max(160).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
});
const anlegenEingabe = z.intersection(stammdaten, lagerung);
const aendernEingabe = z.intersection(stammdaten.partial().extend({ id: z.string().uuid() }), lagerung);
type Vorgabewerte = {
pruefintervallMonate: number | null;
lebensdauerJahre: number | null;
lebensdauerBezug: "HERSTELLUNG" | "INDIENSTSTELLUNG" | null;
};
/**
* Rechnet die abgeleiteten Felder aus. Läuft bei jedem Schreibvorgang, damit
* Termin und Status nie auseinanderlaufen können.
*/
function abgeleiteteFelder(
geraet: {
herstellungsdatum?: Date | null;
indienststellung?: Date | null;
letztePruefung?: Date | null;
pruefintervallMonate?: number | null;
lebensdauerJahre?: number | null;
status: GeraetStatus;
},
typ: Vorgabewerte | null,
) {
const intervall = effektiverWert(geraet.pruefintervallMonate, typ?.pruefintervallMonate);
const jahre = effektiverWert(geraet.lebensdauerJahre, typ?.lebensdauerJahre);
const termin = naechstePruefung({
letztePruefung: geraet.letztePruefung,
indienststellung: geraet.indienststellung,
intervallMonate: intervall,
});
const ende = lebensdauerEnde({
herstellungsdatum: geraet.herstellungsdatum,
indienststellung: geraet.indienststellung,
lebensdauerJahre: jahre,
bezug: typ?.lebensdauerBezug ?? "INDIENSTSTELLUNG",
});
return {
naechstePruefung: termin,
lebensdauerBis: ende,
status: abgeleiteterGeraetStatus({
aktuellerStatus: geraet.status,
naechstePruefung: termin,
lebensdauerBis: ende,
}),
};
}
export const geraetRouter = router({
/** Liste mit Suche, Filter, Sortierung und Blättern. */
liste: angemeldet
.input(
listenEingabe(SORTIERFELDER).extend({
status: z.array(z.nativeEnum(GeraetStatus)).optional(),
geraetetypId: z.string().uuid().optional(),
standortId: z.string().uuid().optional(),
fahrzeugId: z.string().uuid().optional(),
/** Nur Geräte, deren Prüfung fällig ist oder demnächst wird. */
nurFaellig: z.boolean().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.GeraetWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.status?.length ? { status: { in: input.status } } : {}),
...(input.geraetetypId ? { geraetetypId: input.geraetetypId } : {}),
...(input.standortId ? { standortId: input.standortId } : {}),
...(input.fahrzeugId ? { fahrzeugId: input.fahrzeugId } : {}),
...(input.nurFaellig
? { naechstePruefung: { lte: new Date(Date.now() + 30 * 864e5) } }
: {}),
...(sucheUeber(SUCHFELDER, input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.geraet.findMany({
where: wo,
select: LISTEN_AUSWAHL,
orderBy: sortierenNach(input.sortierung, input.richtung, {
inventarnummer: "asc",
}) as Prisma.GeraetOrderByWithRelationInput,
...blaettern(input),
}),
db.geraet.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Ein Gerät mit allem, was die Detailseite braucht. */
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findUnique({
where: { id: input.id },
include: {
geraetetyp: true,
standort: { select: { id: true, name: true, art: true } },
fahrzeug: { select: { id: true, funkrufname: true, bezeichnung: true } },
geraeteraum: { select: { id: true, bezeichnung: true } },
},
});
if (!geraet) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
return geraet;
}),
),
/** Nachschlagen über die Inventarnummer — der Weg vom QR-Code her. */
ueberInventarnummer: angemeldet
.input(z.object({ inventarnummer: z.string().trim().min(1).max(60) }))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const geraet = await db.geraet.findFirst({
where: { inventarnummer: input.inventarnummer, geloeschtAm: null },
select: { id: true, inventarnummer: true, bezeichnung: true, status: true },
});
if (!geraet) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Zur Inventarnummer "${input.inventarnummer}" gibt es kein Gerät.`,
});
}
return geraet;
}),
),
anlegen: mitRecht("geraet.bearbeiten")
.input(anlegenEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = input.geraetetypId
? await db.geraetetyp.findUnique({
where: { id: input.geraetetypId },
select: {
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
})
: null;
const abgeleitet = abgeleiteteFelder({ ...input, status: input.status }, typ);
const geraet = await db.geraet.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
anschaffungspreis: input.anschaffungspreis ?? null,
...abgeleitet,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Gerät ${geraet.inventarnummer} — ${geraet.bezeichnung} angelegt`,
sitzung: ctx.sitzung,
nachher: geraet,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return geraet;
}),
),
aendern: mitRecht("geraet.bearbeiten")
.input(aendernEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraet.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const typId = aenderungen.geraetetypId ?? vorher.geraetetypId;
const typ = typId
? await db.geraetetyp.findUnique({
where: { id: typId },
select: {
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
})
: null;
const zusammengefuehrt = { ...vorher, ...aenderungen };
const abgeleitet = abgeleiteteFelder(
{ ...zusammengefuehrt, status: zusammengefuehrt.status },
typ,
);
const nachher = await db.geraet.update({
where: { id },
data: { ...aenderungen, ...abgeleitet },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: id,
beschreibung: `Gerät ${nachher.inventarnummer} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Massenbearbeitung des Status. Der häufigste Handgriff überhaupt: nach
* einer Sammelprüfung stehen zwanzig Geräte auf einmal wieder auf
* einsatzbereit.
*/
statusSetzen: mitRecht("geraet.bearbeiten")
.input(
idsEingabe.extend({
status: z.nativeEnum(GeraetStatus),
grund: z.string().trim().max(500).optional(),
}),
)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findMany({
where: { id: { in: input.ids }, geloeschtAm: null },
select: { id: true, inventarnummer: true, status: true },
});
if (vorher.length === 0) {
throw new TRPCError({ code: "NOT_FOUND", message: "Keines dieser Geräte gibt es." });
}
await db.geraet.updateMany({
where: { id: { in: vorher.map((g) => g.id) } },
data: {
status: input.status,
...(input.status === GeraetStatus.AUSGESONDERT
? { ausgesondertAm: new Date(), ausgesondertGrund: input.grund ?? null }
: {}),
},
});
// Je Gerät ein Eintrag — der Verlauf soll am einzelnen Gerät hängen,
// nicht an einem Sammelvorgang, den später niemand mehr zuordnen kann.
for (const geraet of vorher) {
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Status von ${geraet.inventarnummer}: ${geraet.status} → ${input.status}${
input.grund ? ` (${input.grund})` : ""
}`,
sitzung: ctx.sitzung,
vorher: { status: geraet.status },
nachher: { status: input.status },
kontext: { sammelvorgang: true, anzahl: vorher.length },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { geaendert: vorher.length };
}),
),
/** Geräte auf ein Fahrzeug oder an einen Standort umsetzen. */
umlagern: mitRecht("geraet.bearbeiten")
.input(idsEingabe.and(lagerung))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { ids, ...ziel } = input;
const vorher = await db.geraet.findMany({
where: { id: { in: ids }, geloeschtAm: null },
select: {
id: true,
inventarnummer: true,
standortId: true,
fahrzeugId: true,
geraeteraumId: true,
},
});
for (const geraet of vorher) {
await db.geraet.update({
where: { id: geraet.id },
data: {
standortId: ziel.standortId ?? null,
fahrzeugId: ziel.fahrzeugId ?? null,
geraeteraumId: ziel.geraeteraumId ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraet",
entitaetId: geraet.id,
beschreibung: `Gerät ${geraet.inventarnummer} umgelagert`,
sitzung: ctx.sitzung,
vorher: {
standortId: geraet.standortId,
fahrzeugId: geraet.fahrzeugId,
geraeteraumId: geraet.geraeteraumId,
},
nachher: ziel,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
}
return { umgelagert: vorher.length };
}),
),
/**
* Soft-Delete. Prüf- und Wartungsdaten werden nie hart gelöscht — das
* verlangen die Projektregeln und die Nachweispflicht gegenüber der
* Unfallkasse.
*/
loeschen: mitRecht("geraet.aussondern")
.input(idEingabe.extend({ grund: z.string().trim().max(500).optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Gerät gibt es nicht." });
}
const nachher = await db.geraet.update({
where: { id: input.id },
data: {
geloeschtAm: new Date(),
status: GeraetStatus.AUSGESONDERT,
ausgesondertAm: new Date(),
ausgesondertGrund: input.grund ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraet",
entitaetId: input.id,
beschreibung: `Gerät ${vorher.inventarnummer} ausgesondert${
input.grund ? `: ${input.grund}` : ""
}`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Rücknahme eines Soft-Deletes. */
wiederherstellen: mitRecht("geraet.aussondern")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraet.findUnique({ where: { id: input.id } });
if (!vorher?.geloeschtAm) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Dieses Gerät ist gar nicht ausgesondert.",
});
}
const nachher = await db.geraet.update({
where: { id: input.id },
data: {
geloeschtAm: null,
ausgesondertAm: null,
ausgesondertGrund: null,
status: GeraetStatus.PRUEFUNG_FAELLIG,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.WIEDERHERGESTELLT,
entitaet: "Geraet",
entitaetId: input.id,
beschreibung: `Gerät ${vorher.inventarnummer} wieder in Dienst genommen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
/**
* Verlauf eines Geräts, gerendert aus dem Audit-Log. Es gibt bewusst keine
* eigene Historientabelle — der Audit-Log ist die Wahrheit, und zwei Quellen
* könnten auseinanderlaufen.
*/
verlauf: angemeldet
.input(idEingabe.extend({ anzahl: z.number().int().min(1).max(200).default(50) }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.auditLog.findMany({
where: { entitaet: "Geraet", entitaetId: input.id },
orderBy: { zeitpunkt: "desc" },
take: input.anzahl,
select: {
id: true,
zeitpunkt: true,
aktion: true,
beschreibung: true,
vorher: true,
nachher: true,
kontext: true,
akteurName: true,
akteur: { select: { id: true, vorname: true, nachname: true } },
},
}),
),
),
/** Zahlen für die Übersicht und die Filterleiste. */
kennzahlen: angemeldet.query(async ({ ctx }) =>
ctx.db(async (db) => {
const nachStatus = await db.geraet.groupBy({
by: ["status"],
where: { geloeschtAm: null },
_count: { _all: true },
});
const in30Tagen = new Date(Date.now() + 30 * 864e5);
const [gesamt, faelligBald, lebensdauerBald] = await Promise.all([
db.geraet.count({ where: { geloeschtAm: null } }),
db.geraet.count({
where: { geloeschtAm: null, naechstePruefung: { lte: in30Tagen } },
}),
db.geraet.count({
where: { geloeschtAm: null, lebensdauerBis: { lte: in30Tagen } },
}),
]);
return {
gesamt,
faelligBald,
lebensdauerBald,
nachStatus: Object.fromEntries(
nachStatus.map((eintrag) => [eintrag.status, eintrag._count._all]),
) as Record<GeraetStatus, number>,
};
}),
),
});
+421
View File
@@ -0,0 +1,421 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { StandortArt } from "@prisma/client";
import { router, mitRecht } from "@/server/trpc";
import type { MandantenClient } from "@/lib/db";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { tabelleLesen, vorschauErstellen, MAX_DATEIGROESSE } from "@/lib/stammdaten/import";
import { IMPORT_ARTEN } from "@/lib/stammdaten/import-felder";
import {
abgeleiteterGeraetStatus,
lebensdauerEnde,
naechstePruefung,
} from "@/lib/stammdaten/fristen";
/**
* Import aus CSV und Excel.
*
* Der Ablauf ist zweistufig und das ist der Punkt: erst Vorschau, dann
* Übernahme. Beide Schritte lesen die Datei neu ein und rechnen serverseitig —
* es werden nie Werte übernommen, die der Browser geschickt hat. Sonst könnte
* jemand die Vorschau umgehen und beliebige Daten hineinschreiben.
*
* Der Preis dafür ist, dass die Datei zweimal übertragen wird. Bei den Größen,
* um die es hier geht (eine Wehr hat selten mehr als ein paar hundert Geräte),
* ist das kein Thema.
*/
const dateiEingabe = z.object({
dateiname: z.string().trim().min(1).max(300),
/** Dateiinhalt als Base64. */
inhalt: z.string().max(Math.ceil((MAX_DATEIGROESSE * 4) / 3) + 1024),
art: z.enum(["geraet", "person", "fahrzeug"]),
/** Spalte der Datei -> Feldname. Ohne Angabe wird automatisch zugeordnet. */
zuordnung: z.record(z.string(), z.string().nullable()).optional(),
});
function pufferAus(base64: string): Buffer {
const daten = Buffer.from(base64, "base64");
if (daten.length === 0) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Die Datei ist leer." });
}
if (daten.length > MAX_DATEIGROESSE) {
throw new TRPCError({
code: "PAYLOAD_TOO_LARGE",
message: `Die Datei ist zu groß. Erlaubt sind ${MAX_DATEIGROESSE / 1024 / 1024} MB.`,
});
}
return daten;
}
export const importRouter = router({
/** Welche Felder es je Datenart gibt — für die Zuordnungsoberfläche. */
felder: mitRecht("geraet.bearbeiten")
.input(z.object({ art: z.enum(["geraet", "person", "fahrzeug"]) }))
.query(({ input }) => ({
art: input.art,
bezeichnung: IMPORT_ARTEN[input.art].bezeichnung,
felder: IMPORT_ARTEN[input.art].felder,
})),
/**
* Schritt 1 und 2: Datei lesen, zuordnen, prüfen. Schreibt nichts.
*/
vorschau: mitRecht("geraet.bearbeiten")
.input(dateiEingabe)
.mutation(async ({ input }) => {
const daten = pufferAus(input.inhalt);
let tabelle;
try {
tabelle = await tabelleLesen(daten, input.dateiname);
} catch (fehler) {
throw new TRPCError({
code: "BAD_REQUEST",
message: fehler instanceof Error ? fehler.message : "Die Datei ließ sich nicht lesen.",
});
}
if (tabelle.spalten.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"In der Datei wurden keine Spaltenüberschriften gefunden. Die erste Zeile muss die Überschriften enthalten.",
});
}
const vorschau = vorschauErstellen(tabelle, input.art, input.zuordnung);
// Nur die ersten 100 Zeilen zurückgeben — die Vorschau soll zeigen, ob
// die Zuordnung stimmt, nicht die ganze Datei durch den Browser schicken.
return {
...vorschau,
zeilen: vorschau.zeilen.slice(0, 100),
vorschauGekuerzt: vorschau.zeilen.length > 100,
};
}),
/**
* Schritt 3: Übernehmen. Liest die Datei erneut, rechnet erneut, und
* schreibt nur, was fehlerfrei durchgeht.
*/
uebernehmen: mitRecht("geraet.bearbeiten")
.input(
dateiEingabe.extend({
/** Vorhandene Datensätze aktualisieren statt zu überspringen. */
vorhandeneAktualisieren: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const daten = pufferAus(input.inhalt);
const tabelle = await tabelleLesen(daten, input.dateiname);
const vorschau = vorschauErstellen(tabelle, input.art, input.zuordnung);
if (vorschau.fehlendePflichtfelder.length > 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Diesen Pflichtfeldern ist keine Spalte zugeordnet: ${vorschau.fehlendePflichtfelder.join(", ")}`,
});
}
const uebernehmbare = vorschau.zeilen.filter((zeile) => zeile.uebernehmbar);
const ergebnis = await ctx.db(async (db) => {
const bilanz = { angelegt: 0, aktualisiert: 0, uebersprungen: 0, fehler: [] as string[] };
// Nachschlagewerke einmal laden statt je Zeile — bei 500 Zeilen macht
// das den Unterschied zwischen zwei Sekunden und zwei Minuten.
const [standorte, fahrzeuge] = await Promise.all([
db.standort.findMany({
where: { geloeschtAm: null },
select: { id: true, name: true },
}),
db.fahrzeug.findMany({
where: { geloeschtAm: null },
select: { id: true, funkrufname: true, kennzeichen: true },
}),
]);
const standortNach = new Map(standorte.map((s) => [s.name.toLowerCase(), s.id]));
const fahrzeugNach = new Map<string, string>();
for (const fahrzeug of fahrzeuge) {
fahrzeugNach.set(fahrzeug.funkrufname.toLowerCase(), fahrzeug.id);
if (fahrzeug.kennzeichen) {
fahrzeugNach.set(fahrzeug.kennzeichen.toLowerCase(), fahrzeug.id);
}
}
let standardStandort =
standorte.find((s) => s.name.toLowerCase().includes("gerätehaus"))?.id ??
standorte[0]?.id;
if (!standardStandort && input.art === "geraet") {
const neu = await db.standort.create({
data: {
organisationId: ctx.sitzung.organisationId,
name: "Gerätehaus",
kuerzel: "GH",
art: StandortArt.GERAETEHAUS,
},
});
standardStandort = neu.id;
standortNach.set("gerätehaus", neu.id);
}
for (const zeile of uebernehmbare) {
try {
if (input.art === "geraet") {
await geraetUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
standortNach,
fahrzeugNach,
standardStandort: standardStandort!,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
} else if (input.art === "fahrzeug") {
await fahrzeugUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
} else {
await personUebernehmen(db, {
organisationId: ctx.sitzung.organisationId,
werte: zeile.werte,
aktualisieren: input.vorhandeneAktualisieren,
bilanz,
});
}
} catch (fehler) {
bilanz.fehler.push(
`Zeile ${zeile.nummer}: ${fehler instanceof Error ? fehler.message : "unbekannter Fehler"}`,
);
}
}
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: IMPORT_ARTEN[input.art].bezeichnung,
beschreibung: `Import aus "${input.dateiname}": ${bilanz.angelegt} angelegt, ${bilanz.aktualisiert} aktualisiert, ${bilanz.uebersprungen} übersprungen`,
sitzung: ctx.sitzung,
kontext: {
dateiname: input.dateiname,
art: input.art,
zeilenInDatei: vorschau.zeilenGesamt,
uebernehmbar: uebernehmbare.length,
fehlerhaft: vorschau.anzahlFehlerhaft,
zuordnung: vorschau.zuordnung,
},
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return bilanz;
});
return {
...ergebnis,
nichtUebernommen: vorschau.anzahlFehlerhaft,
zeilenInDatei: vorschau.zeilenGesamt,
};
}),
});
// -----------------------------------------------------------------------------
// Übernahme je Datenart
// -----------------------------------------------------------------------------
type Bilanz = { angelegt: number; aktualisiert: number; uebersprungen: number; fehler: string[] };
type Db = MandantenClient;
async function geraetUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
standortNach: Map<string, string>;
fahrzeugNach: Map<string, string>;
standardStandort: string;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const inventarnummer = String(werte.inventarnummer);
const vorhanden = await db.geraet.findFirst({
where: { inventarnummer },
select: { id: true },
});
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
// Lagerung auflösen: Fahrzeug hat Vorrang, sonst Standort, sonst Gerätehaus.
const fahrzeugName = typeof werte.fahrzeug === "string" ? werte.fahrzeug.toLowerCase() : null;
const standortName = typeof werte.standort === "string" ? werte.standort.toLowerCase() : null;
const fahrzeugId = fahrzeugName ? (angaben.fahrzeugNach.get(fahrzeugName) ?? null) : null;
let standortId: string | null = null;
if (!fahrzeugId) {
standortId = standortName
? (angaben.standortNach.get(standortName) ?? angaben.standardStandort)
: angaben.standardStandort;
}
const indienststellung = werte.indienststellung as Date | undefined;
const letztePruefung = werte.letztePruefung as Date | undefined;
const intervall = werte.pruefintervallMonate as number | undefined;
const jahre = werte.lebensdauerJahre as number | undefined;
const termin =
(werte.naechstePruefung as Date | undefined) ??
naechstePruefung({ letztePruefung, indienststellung, intervallMonate: intervall }) ??
undefined;
const ende =
lebensdauerEnde({
herstellungsdatum: werte.herstellungsdatum as Date | undefined,
indienststellung,
lebensdauerJahre: jahre,
bezug: "INDIENSTSTELLUNG",
}) ?? undefined;
const daten = {
organisationId: angaben.organisationId,
inventarnummer,
bezeichnung: String(werte.bezeichnung),
hersteller: (werte.hersteller as string) ?? null,
modell: (werte.modell as string) ?? null,
seriennummer: (werte.seriennummer as string) ?? null,
herstellungsdatum: (werte.herstellungsdatum as Date) ?? null,
indienststellung: indienststellung ?? null,
anschaffungspreis: (werte.anschaffungspreis as number) ?? null,
pruefintervallMonate: intervall ?? null,
letztePruefung: letztePruefung ?? null,
naechstePruefung: termin ?? null,
lebensdauerJahre: jahre ?? null,
lebensdauerBis: ende ?? null,
lagerort: (werte.lagerort as string) ?? null,
bemerkung: (werte.bemerkung as string) ?? null,
standortId,
fahrzeugId,
status: abgeleiteterGeraetStatus({
aktuellerStatus:
(werte.status as "EINSATZBEREIT" | undefined) ?? "EINSATZBEREIT",
naechstePruefung: termin,
lebensdauerBis: ende,
}),
};
if (vorhanden) {
await db.geraet.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.geraet.create({ data: daten });
bilanz.angelegt += 1;
}
}
async function fahrzeugUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const funkrufname = String(werte.funkrufname);
const vorhanden = await db.fahrzeug.findFirst({
where: { funkrufname },
select: { id: true },
});
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
const daten = {
organisationId: angaben.organisationId,
funkrufname,
bezeichnung: String(werte.bezeichnung),
dinTyp: (werte.dinTyp as string) ?? null,
kennzeichen: (werte.kennzeichen as string) ?? null,
hersteller: (werte.hersteller as string) ?? null,
aufbauhersteller: (werte.aufbauhersteller as string) ?? null,
fahrgestellnummer: (werte.fahrgestellnummer as string) ?? null,
baujahr: (werte.baujahr as number) ?? null,
erstzulassung: (werte.erstzulassung as Date) ?? null,
tuevBis: (werte.tuevBis as Date) ?? null,
sitzplaetze: (werte.sitzplaetze as number) ?? null,
tankinhalt: (werte.tankinhalt as number) ?? null,
};
if (vorhanden) {
await db.fahrzeug.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.fahrzeug.create({ data: daten });
bilanz.angelegt += 1;
}
}
async function personUebernehmen(
db: Db,
angaben: {
organisationId: string;
werte: Record<string, unknown>;
aktualisieren: boolean;
bilanz: Bilanz;
},
): Promise<void> {
const { werte, bilanz } = angaben;
const mitgliedsnummer = (werte.mitgliedsnummer as string) ?? null;
const email = typeof werte.email === "string" ? werte.email.toLowerCase() : null;
const vorhanden = mitgliedsnummer
? await db.person.findFirst({ where: { mitgliedsnummer }, select: { id: true } })
: email
? await db.person.findFirst({ where: { email }, select: { id: true } })
: null;
if (vorhanden && !angaben.aktualisieren) {
bilanz.uebersprungen += 1;
return;
}
const daten = {
organisationId: angaben.organisationId,
vorname: String(werte.vorname),
nachname: String(werte.nachname),
mitgliedsnummer,
geburtsdatum: (werte.geburtsdatum as Date) ?? null,
email,
telefon: (werte.telefon as string) ?? null,
mobil: (werte.mobil as string) ?? null,
strasse: (werte.strasse as string) ?? null,
plz: (werte.plz as string) ?? null,
ort: (werte.ort as string) ?? null,
eintrittsdatum: (werte.eintrittsdatum as Date) ?? null,
abteilung: (werte.abteilung as string) ?? null,
};
if (vorhanden) {
await db.person.update({ where: { id: vorhanden.id }, data: daten });
bilanz.aktualisiert += 1;
} else {
await db.person.create({ data: daten });
bilanz.angelegt += 1;
}
}
+313
View File
@@ -0,0 +1,313 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import {
GeraeteKategorie,
LebensdauerBezug,
LebensdauerKategorie,
Prisma,
QualifikationsArt,
} from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Gerätetypen und Qualifikationstypen — der Katalog, aus dem sich die
* eigentlichen Daten bedienen.
*
* Was hier steht, ist Vorgabe: das Prüfintervall eines Gerätetyps gilt für
* jedes Gerät dieser Bauart, solange am einzelnen Gerät nichts anderes
* eingetragen ist. Das erspart es, bei vierzig Pressluftatmern vierzigmal
* dasselbe Intervall zu pflegen.
*/
const geraetetypDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kategorie: z.nativeEnum(GeraeteKategorie).default(GeraeteKategorie.SONSTIGES),
hersteller: z.string().trim().max(120).nullish(),
modell: z.string().trim().max(120).nullish(),
pruefintervallMonate: z.number().int().min(1).max(600).nullish(),
pruefvorschrift: z.string().trim().max(200).nullish(),
lebensdauerKategorie: z.nativeEnum(LebensdauerKategorie).default(LebensdauerKategorie.KEINE),
lebensdauerJahre: z.number().int().min(1).max(100).nullish(),
lebensdauerBezug: z.nativeEnum(LebensdauerBezug).default(LebensdauerBezug.INDIENSTSTELLUNG),
vorwarnzeitMonate: z.number().int().min(0).max(60).default(3),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const geraetetypRouter = router({
liste: angemeldet
.input(
listenEingabe(["name", "kategorie", "hersteller", "pruefintervallMonate", "erstelltAm"]).extend({
kategorie: z.array(z.nativeEnum(GeraeteKategorie)).optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.GeraetetypWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.kategorie?.length ? { kategorie: { in: input.kategorie } } : {}),
...(sucheUeber(["name", "hersteller", "modell", "pruefvorschrift"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.geraetetyp.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.GeraetetypOrderByWithRelationInput,
include: { _count: { select: { geraete: true } } },
...blaettern(input),
}),
db.geraetetyp.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.geraetetyp.findMany({
where: { geloeschtAm: null, aktiv: true },
select: {
id: true,
name: true,
kategorie: true,
pruefintervallMonate: true,
lebensdauerJahre: true,
lebensdauerBezug: true,
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.geraetetyp.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!typ) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
return typ;
}),
),
anlegen: mitRecht("geraet.bearbeiten")
.input(geraetetypDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.geraetetyp.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraetetyp",
entitaetId: typ.id,
beschreibung: `Gerätetyp ${typ.name} angelegt`,
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
aendern: mitRecht("geraet.bearbeiten")
.input(geraetetypDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraetetyp.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
const nachher = await db.geraetetyp.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraetetyp",
entitaetId: id,
beschreibung: `Gerätetyp ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("geraet.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraetetyp.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Gerätetyp gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `Dieser Gerätetyp ist noch ${vorher._count.geraete} Geräten zugeordnet. Bitte zuerst umtragen.`,
});
}
await db.geraetetyp.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraetetyp",
entitaetId: input.id,
beschreibung: `Gerätetyp ${vorher.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
// -----------------------------------------------------------------------------
const qualifikationstypDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kuerzel: z.string().trim().max(20).nullish(),
art: z.nativeEnum(QualifikationsArt).default(QualifikationsArt.LEHRGANG),
gueltigkeitMonate: z.number().int().min(1).max(600).nullish(),
vorwarnzeitTage: z.number().int().min(0).max(730).default(90),
einsatzrelevant: z.boolean().default(false),
beschreibung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const qualifikationstypRouter = router({
liste: angemeldet
.input(listenEingabe(["name", "art", "gueltigkeitMonate", "erstelltAm"]))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.QualifikationstypWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(sucheUeber(["name", "kuerzel", "beschreibung"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.qualifikationstyp.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.QualifikationstypOrderByWithRelationInput,
include: { _count: { select: { qualifikationen: true } } },
...blaettern(input),
}),
db.qualifikationstyp.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.qualifikationstyp.findMany({
where: { geloeschtAm: null, aktiv: true },
select: {
id: true,
name: true,
kuerzel: true,
art: true,
gueltigkeitMonate: true,
vorwarnzeitTage: true,
einsatzrelevant: true,
},
orderBy: [{ art: "asc" }, { name: "asc" }],
}),
),
),
anlegen: mitRecht("qualifikation.bearbeiten")
.input(qualifikationstypDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.qualifikationstyp.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Qualifikationstyp",
entitaetId: typ.id,
beschreibung: `Qualifikationstyp ${typ.name} angelegt`,
sitzung: ctx.sitzung,
nachher: typ,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return typ;
}),
),
aendern: mitRecht("qualifikation.bearbeiten")
.input(qualifikationstypDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.qualifikationstyp.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diesen Qualifikationstyp gibt es nicht.",
});
}
const nachher = await db.qualifikationstyp.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Qualifikationstyp",
entitaetId: id,
beschreibung: `Qualifikationstyp ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
});
+305
View File
@@ -0,0 +1,305 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Prisma, StandortArt } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
/**
* Standorte und Geräteräume.
*
* Ein Standort ist ein Ort auf der Landkarte: das Gerätehaus, das Lager, die
* Außenstelle. Ein Geräteraum ist ein Fach am Fahrzeug. Beides zusammen
* beantwortet die Frage, die im Einsatz zählt: wo liegt das Ding?
*/
const standortDaten = z.object({
name: z.string().trim().min(1, "Der Name fehlt.").max(160),
kuerzel: z.string().trim().max(20).nullish(),
art: z.nativeEnum(StandortArt).default(StandortArt.GERAETEHAUS),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
aktiv: z.boolean().default(true),
});
export const standortRouter = router({
liste: angemeldet
.input(listenEingabe(["name", "art", "ort", "erstelltAm"]))
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.StandortWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(sucheUeber(["name", "kuerzel", "ort", "strasse"], input.suche) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.standort.findMany({
where: wo,
orderBy: sortierenNach(input.sortierung, input.richtung, {
name: "asc",
}) as Prisma.StandortOrderByWithRelationInput,
include: {
_count: { select: { geraete: true, fahrzeuge: true } },
},
...blaettern(input),
}),
db.standort.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
/** Kurzliste für Auswahlfelder. */
auswahl: angemeldet.query(async ({ ctx }) =>
ctx.db((db) =>
db.standort.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, name: true, kuerzel: true, art: true },
orderBy: { name: "asc" },
}),
),
),
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const standort = await db.standort.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true, fahrzeuge: true } } },
});
if (!standort) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
return standort;
}),
),
anlegen: mitRecht("organisation.bearbeiten")
.input(standortDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const standort = await db.standort.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Standort",
entitaetId: standort.id,
beschreibung: `Standort ${standort.name} angelegt`,
sitzung: ctx.sitzung,
nachher: standort,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return standort;
}),
),
aendern: mitRecht("organisation.bearbeiten")
.input(standortDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.standort.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
const nachher = await db.standort.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Standort",
entitaetId: id,
beschreibung: `Standort ${nachher.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("organisation.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.standort.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true, fahrzeuge: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Standort gibt es nicht." });
}
// Ein Standort, an dem noch etwas steht, lässt sich nicht entfernen —
// sonst hätten die Geräte plötzlich keinen Ort mehr.
if (vorher._count.geraete > 0 || vorher._count.fahrzeuge > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `An diesem Standort stehen noch ${vorher._count.geraete} Geräte und ${vorher._count.fahrzeuge} Fahrzeuge. Bitte zuerst umlagern.`,
});
}
await db.standort.update({
where: { id: input.id },
data: { geloeschtAm: new Date(), aktiv: false },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Standort",
entitaetId: input.id,
beschreibung: `Standort ${vorher.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
// -----------------------------------------------------------------------------
const raumDaten = z.object({
fahrzeugId: z.string().uuid(),
bezeichnung: z.string().trim().min(1, "Die Bezeichnung fehlt.").max(40),
beschreibung: z.string().trim().max(200).nullish(),
sortierung: z.number().int().min(0).max(999).default(0),
});
export const geraeteraumRouter = router({
/** Alle Räume eines Fahrzeugs, in Beladereihenfolge. */
zuFahrzeug: angemeldet
.input(z.object({ fahrzeugId: z.string().uuid() }))
.query(async ({ ctx, input }) =>
ctx.db((db) =>
db.geraeteraum.findMany({
where: { fahrzeugId: input.fahrzeugId, geloeschtAm: null },
orderBy: [{ sortierung: "asc" }, { bezeichnung: "asc" }],
include: { _count: { select: { geraete: true } } },
}),
),
),
anlegen: mitRecht("fahrzeug.bearbeiten")
.input(raumDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fahrzeug = await db.fahrzeug.findUnique({
where: { id: input.fahrzeugId },
select: { id: true, funkrufname: true },
});
if (!fahrzeug) {
throw new TRPCError({ code: "NOT_FOUND", message: "Dieses Fahrzeug gibt es nicht." });
}
const raum = await db.geraeteraum.create({
data: { organisationId: ctx.sitzung.organisationId, ...input },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Geraeteraum",
entitaetId: raum.id,
beschreibung: `Geräteraum ${raum.bezeichnung} an ${fahrzeug.funkrufname} angelegt`,
sitzung: ctx.sitzung,
nachher: raum,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return raum;
}),
),
aendern: mitRecht("fahrzeug.bearbeiten")
.input(raumDaten.partial().omit({ fahrzeugId: true }).extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.geraeteraum.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Geräteraum gibt es nicht." });
}
const nachher = await db.geraeteraum.update({ where: { id }, data: aenderungen });
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Geraeteraum",
entitaetId: id,
beschreibung: `Geräteraum ${nachher.bezeichnung} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("fahrzeug.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.geraeteraum.findUnique({
where: { id: input.id },
include: { _count: { select: { geraete: true } } },
});
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diesen Geräteraum gibt es nicht." });
}
if (vorher._count.geraete > 0) {
throw new TRPCError({
code: "CONFLICT",
message: `In diesem Geräteraum liegen noch ${vorher._count.geraete} Geräte. Bitte zuerst umlagern.`,
});
}
await db.geraeteraum.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Geraeteraum",
entitaetId: input.id,
beschreibung: `Geräteraum ${vorher.bezeichnung} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
+480
View File
@@ -0,0 +1,480 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { Dienstgrad, Prisma } from "@prisma/client";
import { router, angemeldet, mitRecht } from "@/server/trpc";
import { AuditAktion, protokolliereIn } from "@/lib/audit";
import { darfPersonSehen, KeinZugriffFehler } from "@/lib/authz";
import {
blaettern,
idEingabe,
listenAntwort,
listenEingabe,
ohneGeloeschte,
sortierenNach,
sucheUeber,
} from "@/server/routers/stammdaten/gemeinsam";
import { qualifikationGueltigBis, qualifikationZustand } from "@/lib/stammdaten/fristen";
/**
* Mitglieder und ihre Qualifikationen.
*
* Personendaten sind der heikelste Teil der Anwendung. Zwei Regeln greifen
* hier durchgängig:
* - Die eigenen Daten darf jeder sehen, fremde nur mit dem Recht
* person.lesen. Das prüft darfPersonSehen() aus lib/authz.
* - Gelöscht wird nie hart. Ein ausgetretenes Mitglied bleibt bestehen,
* bis der Aufbewahrungsjob es nach PERSON_ANONYMISIERUNG_TAGE anonymisiert.
*/
const SORTIERFELDER = [
"nachname",
"vorname",
"mitgliedsnummer",
"dienstgrad",
"eintrittsdatum",
"abteilung",
"erstelltAm",
] as const;
const personDaten = z.object({
vorname: z.string().trim().min(1, "Der Vorname fehlt.").max(100),
nachname: z.string().trim().min(1, "Der Nachname fehlt.").max(100),
mitgliedsnummer: z.string().trim().max(30).nullish(),
geburtsdatum: z.coerce.date().nullish(),
dienstgrad: z.nativeEnum(Dienstgrad).default(Dienstgrad.KEIN),
email: z.string().trim().email("Das ist keine gültige E-Mail-Adresse.").max(200).nullish(),
telefon: z.string().trim().max(40).nullish(),
mobil: z.string().trim().max(40).nullish(),
strasse: z.string().trim().max(200).nullish(),
plz: z.string().trim().max(10).nullish(),
ort: z.string().trim().max(120).nullish(),
eintrittsdatum: z.coerce.date().nullish(),
austrittsdatum: z.coerce.date().nullish(),
abteilung: z.string().trim().max(60).nullish(),
aktiv: z.boolean().default(true),
});
export const personRouter = router({
liste: mitRecht("person.lesen")
.input(
listenEingabe(SORTIERFELDER).extend({
abteilung: z.string().trim().max(60).optional(),
nurAktive: z.boolean().default(true),
/** Nur Personen mit diesem Qualifikationstyp. */
qualifikationstypId: z.string().uuid().optional(),
}),
)
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const wo: Prisma.PersonWhereInput = {
...ohneGeloeschte(input.mitGeloeschten),
...(input.nurAktive ? { aktiv: true } : {}),
...(input.abteilung ? { abteilung: input.abteilung } : {}),
...(input.qualifikationstypId
? {
qualifikationen: {
some: { typId: input.qualifikationstypId, geloeschtAm: null },
},
}
: {}),
...(sucheUeber(
["nachname", "vorname", "mitgliedsnummer", "email", "ort"],
input.suche,
) ?? {}),
};
const [eintraege, gesamt] = await Promise.all([
db.person.findMany({
where: wo,
select: {
id: true,
vorname: true,
nachname: true,
mitgliedsnummer: true,
dienstgrad: true,
abteilung: true,
email: true,
mobil: true,
eintrittsdatum: true,
aktiv: true,
geloeschtAm: true,
rollen: { select: { rolle: { select: { schluessel: true, name: true } } } },
_count: { select: { qualifikationen: true } },
},
orderBy: sortierenNach(input.sortierung, input.richtung, {
nachname: "asc",
}) as Prisma.PersonOrderByWithRelationInput,
...blaettern(input),
}),
db.person.count({ where: wo }),
]);
return listenAntwort(eintraege, gesamt, input);
}),
),
auswahl: mitRecht("person.lesen").query(async ({ ctx }) =>
ctx.db((db) =>
db.person.findMany({
where: { geloeschtAm: null, aktiv: true },
select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true },
orderBy: [{ nachname: "asc" }, { vorname: "asc" }],
}),
),
),
/** Eine Person. Die eigenen Daten darf jeder sehen, fremde nur mit Recht. */
einzeln: angemeldet.input(idEingabe).query(async ({ ctx, input }) => {
if (!darfPersonSehen(ctx.sitzung, input.id)) {
throw new KeinZugriffFehler("person.lesen", "Diese Personendaten dürfen Sie nicht sehen.");
}
return ctx.db(async (db) => {
const person = await db.person.findUnique({
where: { id: input.id },
include: {
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
include: { typ: true },
orderBy: { erworbenAm: "desc" },
},
},
});
if (!person) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
// Der Hash gehört auch dann nicht nach draußen, wenn er nur im Speicher
// liegt — das Feld wird hier ausdrücklich entfernt.
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
});
}),
anlegen: mitRecht("person.bearbeiten")
.input(personDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const person = await db.person.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
email: input.email?.toLowerCase() ?? null,
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Person",
entitaetId: person.id,
beschreibung: `Mitglied ${person.nachname}, ${person.vorname} angelegt`,
sitzung: ctx.sitzung,
nachher: person,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = person;
return ohneGeheimnis;
}),
),
aendern: mitRecht("person.bearbeiten")
.input(personDaten.partial().extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.person.findUnique({ where: { id } });
if (!vorher) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const nachher = await db.person.update({
where: { id },
data: {
...aenderungen,
...(aenderungen.email !== undefined
? { email: aenderungen.email?.toLowerCase() ?? null }
: {}),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Person",
entitaetId: id,
beschreibung: `Mitglied ${nachher.nachname}, ${nachher.vorname} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
const { passwortHash: _hash, ...ohneGeheimnis } = nachher;
return ohneGeheimnis;
}),
),
/** Austragen. Soft-Delete, wie bei allem mit Nachweischarakter. */
austragen: mitRecht("person.loeschen")
.input(idEingabe.extend({ austrittsdatum: z.coerce.date().optional() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
if (input.id === ctx.sitzung.personId) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Sie können sich nicht selbst austragen.",
});
}
const vorher = await db.person.findUnique({ where: { id: input.id } });
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const nachher = await db.person.update({
where: { id: input.id },
data: {
aktiv: false,
austrittsdatum: input.austrittsdatum ?? new Date(),
geloeschtAm: new Date(),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Person",
entitaetId: input.id,
beschreibung: `Mitglied ${vorher.nachname}, ${vorher.vorname} ausgetragen`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
/** Welche Abteilungen es gibt — für die Filterleiste. */
abteilungen: mitRecht("person.lesen").query(async ({ ctx }) =>
ctx.db(async (db) => {
const gruppen = await db.person.groupBy({
by: ["abteilung"],
where: { geloeschtAm: null, abteilung: { not: null } },
_count: { _all: true },
});
return gruppen
.filter((gruppe) => gruppe.abteilung)
.map((gruppe) => ({ name: gruppe.abteilung as string, anzahl: gruppe._count._all }))
.sort((a, b) => a.name.localeCompare(b.name, "de"));
}),
),
});
// -----------------------------------------------------------------------------
const qualifikationDaten = z.object({
personId: z.string().uuid(),
typId: z.string().uuid(),
erworbenAm: z.coerce.date(),
/** Leer lassen: dann wird aus der Gültigkeitsdauer des Typs gerechnet. */
gueltigBis: z.coerce.date().nullish(),
ausstellendeStelle: z.string().trim().max(200).nullish(),
nachweisnummer: z.string().trim().max(80).nullish(),
bemerkung: z.string().trim().max(1000).nullish(),
});
export const qualifikationRouter = router({
/** Qualifikationen einer Person. */
zuPerson: angemeldet
.input(z.object({ personId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
if (!darfPersonSehen(ctx.sitzung, input.personId)) {
throw new KeinZugriffFehler("person.lesen");
}
return ctx.db((db) =>
db.qualifikation.findMany({
where: { personId: input.personId, geloeschtAm: null },
include: { typ: true },
orderBy: [{ gueltigBis: "asc" }, { erworbenAm: "desc" }],
}),
);
}),
/**
* Was demnächst abläuft. Speist die Warnung auf der Übersicht.
* Gerechnet wird mit der Vorwarnzeit des jeweiligen Typs, nicht mit einer
* pauschalen Frist — eine G26.3-Untersuchung braucht mehr Vorlauf als eine
* Unterweisung.
*/
ablaufwarnungen: mitRecht("qualifikation.lesen")
.input(z.object({ tage: z.number().int().min(1).max(730).default(90) }).optional())
.query(async ({ ctx, input }) =>
ctx.db(async (db) => {
const fenster = new Date(Date.now() + (input?.tage ?? 90) * 864e5);
const eintraege = await db.qualifikation.findMany({
where: {
geloeschtAm: null,
gueltigBis: { not: null, lte: fenster },
person: { aktiv: true, geloeschtAm: null },
},
include: {
typ: { select: { id: true, name: true, kuerzel: true, vorwarnzeitTage: true, einsatzrelevant: true } },
person: { select: { id: true, vorname: true, nachname: true, mitgliedsnummer: true } },
},
orderBy: { gueltigBis: "asc" },
take: 200,
});
return eintraege.map((eintrag) => ({
...eintrag,
zustand: qualifikationZustand(eintrag.gueltigBis, eintrag.typ.vorwarnzeitTage),
}));
}),
),
anlegen: mitRecht("qualifikation.bearbeiten")
.input(qualifikationDaten)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const typ = await db.qualifikationstyp.findUnique({
where: { id: input.typId },
select: { id: true, name: true, gueltigkeitMonate: true },
});
if (!typ) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diesen Qualifikationstyp gibt es nicht.",
});
}
const person = await db.person.findUnique({
where: { id: input.personId },
select: { id: true, vorname: true, nachname: true },
});
if (!person) {
throw new TRPCError({ code: "NOT_FOUND", message: "Diese Person gibt es nicht." });
}
const qualifikation = await db.qualifikation.create({
data: {
organisationId: ctx.sitzung.organisationId,
...input,
// Ausdrücklich gesetztes Ablaufdatum gewinnt: auf einer Urkunde
// steht manchmal etwas anderes, als die Regel hergäbe.
gueltigBis:
input.gueltigBis ??
qualifikationGueltigBis(input.erworbenAm, typ.gueltigkeitMonate),
bestaetigtVonId: ctx.sitzung.personId,
bestaetigtAm: new Date(),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.ERSTELLT,
entitaet: "Qualifikation",
entitaetId: qualifikation.id,
beschreibung: `${typ.name} für ${person.nachname}, ${person.vorname} eingetragen`,
sitzung: ctx.sitzung,
nachher: qualifikation,
kontext: { personId: person.id },
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return qualifikation;
}),
),
aendern: mitRecht("qualifikation.bearbeiten")
.input(qualifikationDaten.partial().omit({ personId: true }).extend({ id: z.string().uuid() }))
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const { id, ...aenderungen } = input;
const vorher = await db.qualifikation.findUnique({
where: { id },
include: { typ: { select: { name: true, gueltigkeitMonate: true } } },
});
if (!vorher) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diese Qualifikation gibt es nicht.",
});
}
const erworben = aenderungen.erworbenAm ?? vorher.erworbenAm;
const nachher = await db.qualifikation.update({
where: { id },
data: {
...aenderungen,
gueltigBis:
aenderungen.gueltigBis !== undefined
? aenderungen.gueltigBis
: qualifikationGueltigBis(erworben, vorher.typ.gueltigkeitMonate),
},
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GEAENDERT,
entitaet: "Qualifikation",
entitaetId: id,
beschreibung: `${vorher.typ.name} geändert`,
sitzung: ctx.sitzung,
vorher,
nachher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return nachher;
}),
),
loeschen: mitRecht("qualifikation.bearbeiten")
.input(idEingabe)
.mutation(async ({ ctx, input }) =>
ctx.db(async (db) => {
const vorher = await db.qualifikation.findUnique({
where: { id: input.id },
include: { typ: { select: { name: true } } },
});
if (!vorher || vorher.geloeschtAm) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Diese Qualifikation gibt es nicht.",
});
}
await db.qualifikation.update({
where: { id: input.id },
data: { geloeschtAm: new Date() },
});
await protokolliereIn(db, {
organisationId: ctx.sitzung.organisationId,
aktion: AuditAktion.GELOESCHT,
entitaet: "Qualifikation",
entitaetId: input.id,
beschreibung: `${vorher.typ.name} entfernt`,
sitzung: ctx.sitzung,
vorher,
ipAdresse: ctx.ipAdresse,
userAgent: ctx.userAgent,
});
return { erfolgreich: true };
}),
),
});
+36 -38
View File
@@ -2,8 +2,8 @@ import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson"; import superjson from "superjson";
import { ZodError } from "zod"; import { ZodError } from "zod";
import type { Kontext } from "@/server/context"; import type { Kontext } from "@/server/context";
import { KeinZugriffFehler, hatRecht, type Recht } from "@/lib/authz"; import { KeinZugriffFehler, hatRecht, type Recht, type Sitzung } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db"; import { fuerOrganisation, type MandantenClient } from "@/lib/db";
/** /**
* tRPC-Grundgerüst. * tRPC-Grundgerüst.
@@ -41,10 +41,37 @@ export const createCallerFactory = t.createCallerFactory;
/** Ohne Anmeldung erreichbar. */ /** Ohne Anmeldung erreichbar. */
export const oeffentlich = t.procedure; export const oeffentlich = t.procedure;
/** Übersetzt KeinZugriffFehler in ein sauberes tRPC-FORBIDDEN. */ /** Abfrage im Mandantenkontext der Sitzung. */
const fehlerUebersetzen = middleware(async ({ next }) => { export type MandantenAbfrage = <T>(fn: (db: MandantenClient) => Promise<T>) => Promise<T>;
/**
* Verlangt eine Sitzung, übersetzt Berechtigungsfehler und stellt den
* mandantengebundenen Zugriff bereit.
*
* Die Middleware wird bewusst als Funktion an .use() übergeben und nicht über
* t.middleware() gebaut: nur so reicht tRPC den erweiterten Kontext an die
* folgenden Prozeduren weiter, und ctx.sitzung ist dort nicht mehr nullbar.
*/
export const angemeldet = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Bitte zuerst anmelden." });
}
const sitzung: Sitzung = ctx.sitzung;
try { try {
return await next(); return await next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: (<T>(fn: (db: MandantenClient) => Promise<T>) =>
fuerOrganisation(sitzung.organisationId, fn)) as MandantenAbfrage,
},
});
} catch (fehler) { } catch (fehler) {
if (fehler instanceof KeinZugriffFehler) { if (fehler instanceof KeinZugriffFehler) {
throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler }); throw new TRPCError({ code: "FORBIDDEN", message: fehler.message, cause: fehler });
@@ -53,47 +80,18 @@ const fehlerUebersetzen = middleware(async ({ next }) => {
} }
}); });
/** Verlangt eine Sitzung und stellt den mandantengebundenen Zugriff bereit. */
const angemeldetMiddleware = middleware(async ({ ctx, next }) => {
if (!ctx.sitzung) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Bitte zuerst anmelden.",
});
}
const sitzung = ctx.sitzung;
return next({
ctx: {
...ctx,
sitzung,
/**
* Führt eine Abfrage im Mandantenkontext der Sitzung aus.
* @example const personen = await ctx.db((db) => db.person.findMany());
*/
db: <T>(fn: Parameters<typeof fuerOrganisation<T>>[1]) =>
fuerOrganisation(sitzung.organisationId, fn),
},
});
});
export const angemeldet = t.procedure.use(fehlerUebersetzen).use(angemeldetMiddleware);
/** /**
* Prozedur, die ein bestimmtes Recht verlangt. * Prozedur, die ein bestimmtes Recht verlangt.
* @example export const geraeteAendern = mitRecht("geraet.bearbeiten").mutation(...) * @example export const geraetAendern = mitRecht("geraet.bearbeiten").mutation(...)
*/ */
export function mitRecht(recht: Recht) { export function mitRecht(recht: Recht) {
return angemeldet.use( return angemeldet.use(async ({ ctx, next }) => {
middleware(async ({ ctx, next }) => {
if (!hatRecht(ctx.sitzung, recht)) { if (!hatRecht(ctx.sitzung, recht)) {
throw new TRPCError({ throw new TRPCError({
code: "FORBIDDEN", code: "FORBIDDEN",
message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`, message: `Für diesen Vorgang fehlt die Berechtigung "${recht}".`,
}); });
} }
return next({ ctx }); return next();
}), });
);
} }
+211
View File
@@ -0,0 +1,211 @@
import { expect, test, type Page } from "@playwright/test";
/**
* E2E-Tests für Phase 1.
*
* Geprüft werden die Wege, die eine Wehr täglich geht: in der Geräteliste
* suchen, ein Gerät öffnen, die Beladung eines Fahrzeugs ansehen, und über
* einen gescannten QR-Code auf der richtigen Seite landen.
*
* Voraussetzung: Stack läuft, Datenbank ist migriert und geseedet.
*/
const ADMIN_EMAIL = process.env.SEED_ADMIN_EMAIL ?? "admin@beispielwehr.de";
const ADMIN_PASSWORT = process.env.SEED_ADMIN_PASSWORT ?? "Feuerwehr2026";
async function anmelden(page: Page): Promise<void> {
await page.goto("/anmelden");
await page.getByLabel("E-Mail-Adresse").fill(ADMIN_EMAIL);
await page.getByLabel("Passwort").fill(ADMIN_PASSWORT);
await page.getByRole("button", { name: "Anmelden" }).click();
await page.waitForURL("/");
}
test.beforeEach(async ({ page }) => {
await anmelden(page);
});
test.describe("Geräteliste", () => {
test("zeigt die Geräte der Wehr", async ({ page }) => {
await page.goto("/geraete");
await expect(page.getByRole("heading", { name: "Geräte" })).toBeVisible();
// Aus dem Seed: die Inventarnummern beginnen bei INV-0001.
await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible();
await expect(page.getByText(/\d+ Geräte/)).toBeVisible();
});
test("filtert über die Suche", async ({ page }) => {
await page.goto("/geraete");
await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible();
await page.getByLabel("Geräte durchsuchen").fill("Pressluftatmer");
// Die Suche ist entprellt — deshalb auf das Ergebnis warten, nicht auf die Zeit.
await expect(page.getByRole("cell", { name: "Pressluftatmer PA 94" }).first()).toBeVisible();
await expect(page.getByRole("cell", { name: "Feuerwehrhelm F220" })).toHaveCount(0);
});
test("meldet verständlich, wenn die Suche nichts findet", async ({ page }) => {
await page.goto("/geraete");
await page.getByLabel("Geräte durchsuchen").fill("gibtesnichtxyz");
await expect(page.getByText("Kein Gerät gefunden")).toBeVisible();
});
test("blendet Spalten aus und wieder ein", async ({ page }) => {
await page.goto("/geraete");
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toBeVisible();
await page.getByRole("button", { name: "Spalten" }).click();
await page.getByRole("checkbox", { name: "Hersteller" }).click();
await expect(page.getByRole("columnheader", { name: /Hersteller/i })).toHaveCount(0);
});
test("zeigt Sammelaktionen, sobald etwas ausgewählt ist", async ({ page }) => {
await page.goto("/geraete");
await expect(page.getByRole("link", { name: "INV-0001" })).toBeVisible();
await page.getByRole("checkbox", { name: "INV-0001 auswählen" }).click();
await expect(page.getByText("1 Gerät ausgewählt")).toBeVisible();
await expect(page.getByRole("button", { name: "Etiketten drucken" })).toBeVisible();
});
});
test.describe("Gerätedetail", () => {
test("zeigt Angaben, QR-Code und Lagerung", async ({ page }) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0001" }).click();
await expect(page.getByRole("heading", { name: "Angaben" })).toBeVisible();
await expect(page.getByRole("heading", { name: "QR-Code" })).toBeVisible();
await expect(page.getByAltText(/QR-Code für INV-0001/)).toBeVisible();
await expect(page.getByText("Lagerung")).toBeVisible();
});
test("übernimmt das Prüfintervall aus dem Gerätetyp, wenn am Gerät keines steht", async ({
page,
}) => {
await page.goto("/geraete");
await page.getByRole("link", { name: "INV-0001" }).click();
await expect(page.getByText(/aus dem Gerätetyp/)).toBeVisible();
});
});
test.describe("QR-Route", () => {
test("leitet von /g/<inventarnummer> auf das Gerät weiter", async ({ page }) => {
await page.goto("/g/INV-0001");
await expect(page).toHaveURL(/\/geraete\/[0-9a-f-]{36}/);
await expect(page.getByText("INV-0001").first()).toBeVisible();
});
test("erklärt verständlich, wenn es die Nummer nicht gibt", async ({ page }) => {
await page.goto("/g/INV-GIBTESNICHT");
await expect(page.getByText("Kein Gerät gefunden")).toBeVisible();
await expect(page.getByText("INV-GIBTESNICHT")).toBeVisible();
});
test("schickt nicht Angemeldete erst zur Anmeldung", async ({ browser }) => {
// Frischer Kontext ohne Sitzungscookie.
const kontext = await browser.newContext();
const seite = await kontext.newPage();
await seite.goto("/g/INV-0001");
await expect(seite).toHaveURL(/\/anmelden/);
await kontext.close();
});
});
test.describe("Fahrzeuge", () => {
test("zeigt die Fahrzeuge als Kacheln", async ({ page }) => {
await page.goto("/fahrzeuge");
await expect(page.getByRole("heading", { name: "Fahrzeuge" })).toBeVisible();
await expect(page.getByRole("link", { name: /Florian Musterhausen 40\/1/ })).toBeVisible();
});
test("zeigt die Beladung nach Geräteräumen", async ({ page }) => {
await page.goto("/fahrzeuge");
await page.getByRole("link", { name: /Florian Musterhausen 40\/1/ }).click();
await expect(page.getByRole("heading", { name: "Beladung" })).toBeVisible();
// Die Beladeordnung aus dem Seed: G1 bis G6, Heck, Dach, Mannschaftsraum.
await expect(page.getByText("G1", { exact: true })).toBeVisible();
await expect(page.getByText("Dach", { exact: true })).toBeVisible();
});
});
test.describe("Qualifikationen", () => {
test("zeigt Ablaufwarnungen und den Katalog", async ({ page }) => {
await page.goto("/qualifikationen");
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Läuft ab" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Katalog" })).toBeVisible();
await expect(page.getByText("Atemschutzgeräteträger").first()).toBeVisible();
});
});
test.describe("Mitglieder", () => {
test("gruppiert nach Abteilung", async ({ page }) => {
await page.goto("/mitglieder");
await expect(page.getByRole("heading", { name: "Mitglieder" })).toBeVisible();
await expect(page.getByRole("heading", { name: /^Aktive \(/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Huber, Andreas" })).toBeVisible();
});
test("zeigt Qualifikationen am Mitglied", async ({ page }) => {
await page.goto("/mitglieder");
await page.getByRole("link", { name: "Huber, Andreas" }).click();
await expect(page.getByRole("heading", { name: "Qualifikationen" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Terminal-Zugang" })).toBeVisible();
});
});
test.describe("Import", () => {
test("führt durch die drei Schritte und zeigt die Vorschau", async ({ page }) => {
await page.goto("/geraete/importieren");
await expect(page.getByRole("heading", { name: "1. Datei wählen" })).toBeVisible();
const csv = [
"Inventarnummer;Bezeichnung;Hersteller;Indienststellung",
"E2E-001;Testgerät A;Beispiel;01.03.2024",
"E2E-002;Testgerät B;Beispiel;kein Datum",
].join("\n");
await page.setInputFiles("#datei", {
name: "test-geraete.csv",
mimeType: "text/csv",
buffer: Buffer.from(csv, "utf8"),
});
// Schritt 2 erscheint erst, wenn der Server die Datei gelesen hat.
await expect(page.getByRole("heading", { name: "2. Spalten zuordnen" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Vorschau" })).toBeVisible();
// Eine Zeile ist gut, eine hat ein unlesbares Datum.
await expect(page.getByText("1 von 2 Zeilen sind übernehmbar")).toBeVisible();
await expect(page.getByRole("button", { name: /1 Zeilen übernehmen/ })).toBeEnabled();
});
test("verweigert die Übernahme, wenn ein Pflichtfeld nicht zugeordnet ist", async ({ page }) => {
await page.goto("/geraete/importieren");
await page.setInputFiles("#datei", {
name: "unvollstaendig.csv",
mimeType: "text/csv",
buffer: Buffer.from("Inventarnummer\nE2E-100\n", "utf8"),
});
await expect(page.getByText(/Diesen Pflichtfeldern ist keine Spalte zugeordnet/)).toBeVisible();
});
});
+10
View File
@@ -0,0 +1,10 @@
/**
* Ersatz für das Paket "server-only" im Testlauf.
*
* Das echte Paket wirft beim Import, sobald es außerhalb einer Server-
* Komponente geladen wird — genau dafür ist es da. Vitest ist aber weder
* Server noch Browser, und Module wie lib/stammdaten/import.ts sollen sich
* trotzdem testen lassen. Die Schutzwirkung im Anwendungscode bleibt
* unberührt: dort wird weiterhin das echte Paket eingebunden.
*/
export {};
+308
View File
@@ -0,0 +1,308 @@
import { describe, expect, it } from "vitest";
import {
GERAET_FELDER,
PERSON_FELDER,
auswahlLesen,
datumLesen,
dezimalLesen,
jaNeinLesen,
normalisiereUeberschrift,
spaltenZuordnen,
wertLesen,
zahlLesen,
} from "@/lib/stammdaten/import-felder";
import { csvLesen, formatErkennen, textAusPuffer, vorschauErstellen } from "@/lib/stammdaten/import";
/**
* Tests für den Import.
*
* Der Import ist die Stelle, an der eine Wehr das Vertrauen in die Software
* gewinnt oder verliert: Wenn hier Spalten verrutschen, stehen später falsche
* Seriennummern an falschen Geräten, und das merkt niemand, bis jemand am
* Regal steht.
*
* Geprüft wird deshalb nicht nur der Normalfall, sondern vor allem das, was
* in echten Tabellen tatsächlich vorkommt: deutsche Datumsformate, Semikolon
* als Trennzeichen, Windows-1252, Excel-Seriennummern, Dubletten.
*/
describe("Datum lesen", () => {
it("versteht das deutsche Format", () => {
expect(datumLesen("02.09.2026").wert).toEqual(new Date(Date.UTC(2026, 8, 2, 12)));
expect(datumLesen("2.9.2026").wert).toEqual(new Date(Date.UTC(2026, 8, 2, 12)));
});
it("versteht zweistellige Jahre nach der üblichen Grenze", () => {
expect(datumLesen("02.09.26").wert?.getUTCFullYear()).toBe(2026);
expect(datumLesen("02.09.85").wert?.getUTCFullYear()).toBe(1985);
});
it("versteht ISO", () => {
expect(datumLesen("2026-09-02").wert).toEqual(new Date(Date.UTC(2026, 8, 2, 12)));
});
it("versteht die Seriennummer, die Excel intern benutzt", () => {
// 45000 Tage nach dem 30.12.1899 ist der 15.03.2023.
const ergebnis = datumLesen("45000");
expect(ergebnis.ok).toBe(true);
expect(ergebnis.wert?.getUTCFullYear()).toBe(2023);
expect(ergebnis.wert?.getUTCMonth()).toBe(2);
expect(ergebnis.wert?.getUTCDate()).toBe(15);
});
it("weist Tage ab, die es nicht gibt", () => {
expect(datumLesen("31.02.2026").ok).toBe(false);
expect(datumLesen("32.01.2026").ok).toBe(false);
expect(datumLesen("01.13.2026").ok).toBe(false);
});
it("weist unplausible Jahre ab", () => {
expect(datumLesen("01.01.1850").ok).toBe(false);
expect(datumLesen("01.01.2200").ok).toBe(false);
});
it("lässt leere Felder durch, ohne einen Wert zu setzen", () => {
const ergebnis = datumLesen(" ");
expect(ergebnis.ok).toBe(true);
expect(ergebnis.wert).toBeUndefined();
});
it("meldet Unsinn mit einer verständlichen Meldung", () => {
const ergebnis = datumLesen("demnächst");
expect(ergebnis.ok).toBe(false);
expect(ergebnis.fehler).toContain("TT.MM.JJJJ");
});
});
describe("Zahlen lesen", () => {
it("versteht Tausenderpunkte", () => {
expect(zahlLesen("1.234").wert).toBe(1234);
expect(zahlLesen("12").wert).toBe(12);
});
it("versteht deutsche und englische Dezimalschreibweise", () => {
expect(dezimalLesen("1.234,56").wert).toBeCloseTo(1234.56);
expect(dezimalLesen("1,234.56").wert).toBeCloseTo(1234.56);
expect(dezimalLesen("1234,56").wert).toBeCloseTo(1234.56);
expect(dezimalLesen("1234.56").wert).toBeCloseTo(1234.56);
});
it("verkraftet ein Eurozeichen", () => {
expect(dezimalLesen("1.899,00 €").wert).toBeCloseTo(1899);
});
it("weist Text ab", () => {
expect(zahlLesen("etwa zwölf").ok).toBe(false);
expect(dezimalLesen("teuer").ok).toBe(false);
});
});
describe("Ja/Nein lesen", () => {
it("versteht die Schreibweisen aus echten Tabellen", () => {
for (const wahr of ["ja", "J", "x", "1", "WAHR", "true"]) {
expect(jaNeinLesen(wahr).wert, wahr).toBe(true);
}
for (const falsch of ["nein", "N", "0", "falsch", "-"]) {
expect(jaNeinLesen(falsch).wert, falsch).toBe(false);
}
});
it("weist Unklares ab, statt zu raten", () => {
expect(jaNeinLesen("vielleicht").ok).toBe(false);
});
});
describe("Auswahlwerte", () => {
const auswahl = { einsatzbereit: "EINSATZBEREIT", "Prüfung fällig": "PRUEFUNG_FAELLIG" };
it("erkennt die Anzeigeform", () => {
expect(auswahlLesen("einsatzbereit", auswahl).wert).toBe("EINSATZBEREIT");
});
it("erkennt auch den gespeicherten Wert", () => {
expect(auswahlLesen("PRUEFUNG_FAELLIG", auswahl).wert).toBe("PRUEFUNG_FAELLIG");
});
it("ist bei Umlauten und Schreibweise nachsichtig", () => {
expect(auswahlLesen("pruefung faellig", auswahl).wert).toBe("PRUEFUNG_FAELLIG");
expect(auswahlLesen(" Einsatzbereit ", auswahl).wert).toBe("EINSATZBEREIT");
});
it("nennt bei einem falschen Wert die erlaubten", () => {
const ergebnis = auswahlLesen("kaputt", auswahl);
expect(ergebnis.ok).toBe(false);
expect(ergebnis.fehler).toContain("einsatzbereit");
});
});
describe("Überschriften normalisieren", () => {
it("macht Umlaute und Sonderzeichen vergleichbar", () => {
expect(normalisiereUeberschrift("Nächste Prüfung")).toBe("naechstepruefung");
expect(normalisiereUeberschrift("INVENTAR-NR.")).toBe("inventarnr");
expect(normalisiereUeberschrift("Größe (kg)")).toBe("groessekg");
});
});
describe("Spalten zuordnen", () => {
it("erkennt die üblichen Überschriften von selbst", () => {
const zuordnung = spaltenZuordnen(
["Inventarnummer", "Bezeichnung", "Hersteller", "Seriennummer"],
GERAET_FELDER,
);
expect(zuordnung["Inventarnummer"]).toBe("inventarnummer");
expect(zuordnung["Bezeichnung"]).toBe("bezeichnung");
expect(zuordnung["Hersteller"]).toBe("hersteller");
expect(zuordnung["Seriennummer"]).toBe("seriennummer");
});
it("erkennt auch abweichende Schreibweisen", () => {
const zuordnung = spaltenZuordnen(["Inv.-Nr.", "Fabrikat", "Serien-Nr"], GERAET_FELDER);
expect(zuordnung["Inv.-Nr."]).toBe("inventarnummer");
expect(zuordnung["Fabrikat"]).toBe("hersteller");
expect(zuordnung["Serien-Nr"]).toBe("seriennummer");
});
it("vergibt jedes Zielfeld höchstens einmal", () => {
const zuordnung = spaltenZuordnen(["Name", "Bezeichnung"], GERAET_FELDER);
const vergeben = Object.values(zuordnung).filter(Boolean);
expect(new Set(vergeben).size).toBe(vergeben.length);
});
it("lässt unbekannte Spalten leer, statt zu raten", () => {
const zuordnung = spaltenZuordnen(["Lieblingsfarbe"], PERSON_FELDER);
expect(zuordnung["Lieblingsfarbe"]).toBeNull();
});
});
describe("Werte gegen die Feldbeschreibung prüfen", () => {
const inventarnummer = GERAET_FELDER.find((feld) => feld.schluessel === "inventarnummer")!;
const bemerkung = GERAET_FELDER.find((feld) => feld.schluessel === "bemerkung")!;
it("meldet ein fehlendes Pflichtfeld", () => {
const ergebnis = wertLesen(inventarnummer, "");
expect(ergebnis.ok).toBe(false);
expect(ergebnis.fehler).toContain("Pflichtfeld");
});
it("meldet zu lange Werte mit Zahlen", () => {
const ergebnis = wertLesen(bemerkung, "x".repeat(1500));
expect(ergebnis.ok).toBe(false);
expect(ergebnis.fehler).toContain("1500");
});
});
describe("CSV lesen", () => {
it("erkennt das Semikolon, das Excel im Deutschen schreibt", () => {
const tabelle = csvLesen("Inventarnummer;Bezeichnung\nINV-001;Pressluftatmer\n");
expect(tabelle.spalten).toEqual(["Inventarnummer", "Bezeichnung"]);
expect(tabelle.zeilen).toHaveLength(1);
expect(tabelle.zeilen[0]?.["Bezeichnung"]).toBe("Pressluftatmer");
});
it("kommt auch mit Komma zurecht", () => {
const tabelle = csvLesen("Inventarnummer,Bezeichnung\nINV-001,Schlauch\n");
expect(tabelle.zeilen[0]?.["Bezeichnung"]).toBe("Schlauch");
});
it("überspringt leere Zeilen", () => {
const tabelle = csvLesen("A;B\n1;2\n\n\n3;4\n");
expect(tabelle.zeilen).toHaveLength(2);
});
});
describe("Zeichensatz erkennen", () => {
it("entfernt eine UTF-8-BOM", () => {
const puffer = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from("Prüfung", "utf8")]);
expect(textAusPuffer(puffer)).toBe("Prüfung");
});
it("fällt bei Windows-1252 auf latin1 zurück", () => {
// "Prüfung" in Windows-1252: das ü ist ein einzelnes Byte 0xFC.
const puffer = Buffer.from([0x50, 0x72, 0xfc, 0x66, 0x75, 0x6e, 0x67]);
expect(textAusPuffer(puffer)).toBe("Prüfung");
});
});
describe("Formaterkennung", () => {
it("erkennt die unterstützten Endungen", () => {
expect(formatErkennen("geraete.csv")).toBe("csv");
expect(formatErkennen("Geräte.XLSX")).toBe("xlsx");
expect(formatErkennen("export.txt")).toBe("csv");
});
it("gibt bei allem anderen null zurück", () => {
expect(formatErkennen("bestand.pdf")).toBeNull();
expect(formatErkennen("ohne-endung")).toBeNull();
});
});
describe("Vorschau", () => {
const csv = [
"Inventarnummer;Bezeichnung;Hersteller;Indienststellung;Preis",
"INV-001;Pressluftatmer;Dräger;01.03.2019;1.899,00",
"INV-002;Feuerwehrleine;Bauer;15.06.2021;249,50",
";Ohne Nummer;Unbekannt;01.01.2020;100",
"INV-001;Doppelte Nummer;Dräger;01.01.2020;100",
"INV-004;Schlauch;Ziegler;kein Datum;80",
].join("\n");
const vorschau = () => vorschauErstellen(csvLesen(csv), "geraet");
it("ordnet die Spalten von selbst zu", () => {
const ergebnis = vorschau();
expect(ergebnis.zuordnung["Inventarnummer"]).toBe("inventarnummer");
expect(ergebnis.zuordnung["Preis"]).toBe("anschaffungspreis");
});
it("wandelt Werte in den richtigen Typ", () => {
const erste = vorschau().zeilen[0];
expect(erste?.werte.inventarnummer).toBe("INV-001");
expect(erste?.werte.anschaffungspreis).toBeCloseTo(1899);
expect(erste?.werte.indienststellung).toBeInstanceOf(Date);
});
it("markiert die Zeile ohne Pflichtfeld als nicht übernehmbar", () => {
const dritte = vorschau().zeilen[2];
expect(dritte?.uebernehmbar).toBe(false);
expect(dritte?.fehler.some((f) => f.meldung.includes("Pflichtfeld"))).toBe(true);
});
it("erkennt Dubletten innerhalb der Datei und nennt die erste Zeile", () => {
const vierte = vorschau().zeilen[3];
expect(vierte?.uebernehmbar).toBe(false);
expect(vierte?.fehler.some((f) => f.meldung.includes("Zeile 1"))).toBe(true);
});
it("markiert ein unlesbares Datum, ohne die Zeile stillschweigend zu verwerfen", () => {
const fuenfte = vorschau().zeilen[4];
expect(fuenfte?.uebernehmbar).toBe(false);
expect(fuenfte?.fehler[0]?.feld).toBe("Indienststellung");
});
it("zählt übernehmbare und fehlerhafte Zeilen getrennt", () => {
const ergebnis = vorschau();
expect(ergebnis.anzahlUebernehmbar).toBe(2);
expect(ergebnis.anzahlFehlerhaft).toBe(3);
expect(ergebnis.zeilen).toHaveLength(5);
});
it("meldet Pflichtfelder, denen gar keine Spalte zugeordnet ist", () => {
const ohneBezeichnung = vorschauErstellen(csvLesen("Inventarnummer\nINV-001\n"), "geraet");
expect(ohneBezeichnung.fehlendePflichtfelder).toContain("Bezeichnung");
expect(ohneBezeichnung.anzahlUebernehmbar).toBe(0);
});
it("folgt einer von Hand geänderten Zuordnung", () => {
const tabelle = csvLesen("Spalte A;Spalte B\nINV-9;Schlauch\n");
const ergebnis = vorschauErstellen(tabelle, "geraet", {
"Spalte A": "inventarnummer",
"Spalte B": "bezeichnung",
});
expect(ergebnis.anzahlUebernehmbar).toBe(1);
expect(ergebnis.zeilen[0]?.werte.inventarnummer).toBe("INV-9");
});
});
+254
View File
@@ -0,0 +1,254 @@
import { describe, expect, it } from "vitest";
import { inventarnummerAusScan } from "@/lib/stammdaten/qr-parser";
import {
abgeleiteterGeraetStatus,
effektiverWert,
fristzustand,
lebensdauerEnde,
naechstePruefung,
qualifikationGueltigBis,
tageBis,
} from "@/lib/stammdaten/fristen";
/**
* Tests für die QR-Route und die Fristenrechnung.
*
* Beides entscheidet darüber, ob ein Gerät als einsatzbereit gilt. Ein Fehler
* in der Fristenrechnung ist gefährlicher als ein Fehler in der Oberfläche:
* Er fällt niemandem auf, bis die Unfallkasse fragt.
*
* Alle Zeitangaben werden mit einem festen "heute" gerechnet, damit die Tests
* nicht von der Uhr abhängen und um Mitternacht anders ausgehen.
*/
const HEUTE = new Date(Date.UTC(2026, 8, 3, 10, 0, 0)); // 3. September 2026
describe("QR-Scan auswerten", () => {
it("liest die Inventarnummer aus der eigenen URL", () => {
expect(inventarnummerAusScan("https://florian.beispielwehr.de/g/INV-0042")).toBe("INV-0042");
});
it("liest sie auch aus einem bloßen Pfad", () => {
expect(inventarnummerAusScan("/g/INV-0042")).toBe("INV-0042");
});
it("dekodiert Sonderzeichen aus der URL", () => {
expect(inventarnummerAusScan("https://florian.local/g/INV%2F12")).toBe("INV/12");
});
it("nimmt eine blanke Nummer an — alte Etiketten sollen weiter gehen", () => {
expect(inventarnummerAusScan("INV-0042")).toBe("INV-0042");
expect(inventarnummerAusScan(" 12345 ")).toBe("12345");
});
it("weist fremde URLs ab", () => {
expect(inventarnummerAusScan("https://www.draeger.com/produkt/pa94")).toBeNull();
expect(inventarnummerAusScan("http://beispiel.de")).toBeNull();
});
it("weist leere und unsinnige Eingaben ab", () => {
expect(inventarnummerAusScan("")).toBeNull();
expect(inventarnummerAusScan(" ")).toBeNull();
expect(inventarnummerAusScan("WIFI:S=Geraetehaus;T=WPA;P=geheim;;")).toBeNull();
});
it("weist übermäßig lange Zeichenketten ab", () => {
expect(inventarnummerAusScan("A".repeat(200))).toBeNull();
});
});
describe("Nächste Prüfung", () => {
it("rechnet ab der letzten Prüfung", () => {
const termin = naechstePruefung({
letztePruefung: new Date(Date.UTC(2026, 2, 1)),
intervallMonate: 12,
});
expect(termin?.getUTCFullYear()).toBe(2027);
expect(termin?.getUTCMonth()).toBe(2);
});
it("nimmt ersatzweise die Indienststellung, wenn noch nie geprüft wurde", () => {
const termin = naechstePruefung({
indienststellung: new Date(Date.UTC(2026, 0, 15)),
intervallMonate: 6,
});
expect(termin?.getUTCMonth()).toBe(6);
});
it("bevorzugt die letzte Prüfung vor der Indienststellung", () => {
const termin = naechstePruefung({
letztePruefung: new Date(Date.UTC(2026, 5, 1)),
indienststellung: new Date(Date.UTC(2020, 0, 1)),
intervallMonate: 12,
});
expect(termin?.getUTCFullYear()).toBe(2027);
});
it("gibt ohne Intervall keinen Termin zurück — das ist kein Fehler", () => {
expect(naechstePruefung({ letztePruefung: HEUTE, intervallMonate: null })).toBeNull();
expect(naechstePruefung({ letztePruefung: HEUTE, intervallMonate: 0 })).toBeNull();
});
it("gibt ohne jeden Anhaltspunkt keinen Termin zurück", () => {
expect(naechstePruefung({ intervallMonate: 12 })).toBeNull();
});
});
describe("Lebensdauer", () => {
it("rechnet ab Indienststellung, wenn das die Vorgabe ist", () => {
const ende = lebensdauerEnde({
herstellungsdatum: new Date(Date.UTC(2010, 0, 1)),
indienststellung: new Date(Date.UTC(2012, 0, 1)),
lebensdauerJahre: 10,
bezug: "INDIENSTSTELLUNG",
});
expect(ende?.getUTCFullYear()).toBe(2022);
});
it("rechnet ab Herstellung, wenn die Vorschrift das verlangt", () => {
// Seile und Druckbehälter altern ab Herstellung, nicht ab Einbau.
const ende = lebensdauerEnde({
herstellungsdatum: new Date(Date.UTC(2010, 0, 1)),
indienststellung: new Date(Date.UTC(2012, 0, 1)),
lebensdauerJahre: 10,
bezug: "HERSTELLUNG",
});
expect(ende?.getUTCFullYear()).toBe(2020);
});
it("weicht auf das andere Datum aus, wenn eines fehlt", () => {
const ende = lebensdauerEnde({
indienststellung: new Date(Date.UTC(2015, 0, 1)),
lebensdauerJahre: 5,
bezug: "HERSTELLUNG",
});
expect(ende?.getUTCFullYear()).toBe(2020);
});
it("gibt ohne Lebensdauer nichts zurück", () => {
expect(
lebensdauerEnde({ indienststellung: HEUTE, lebensdauerJahre: null }),
).toBeNull();
});
});
describe("Fristzustand", () => {
it("erkennt überfällig, fällig und in Ordnung", () => {
expect(fristzustand(new Date(Date.UTC(2026, 7, 1)), 30, HEUTE)).toBe("ueberfaellig");
expect(fristzustand(new Date(Date.UTC(2026, 8, 3)), 30, HEUTE)).toBe("faellig");
expect(fristzustand(new Date(Date.UTC(2026, 8, 20)), 30, HEUTE)).toBe("bald_faellig");
expect(fristzustand(new Date(Date.UTC(2027, 0, 1)), 30, HEUTE)).toBe("in_ordnung");
});
it("achtet auf die Vorwarnzeit", () => {
const in60Tagen = new Date(Date.UTC(2026, 10, 2));
expect(fristzustand(in60Tagen, 30, HEUTE)).toBe("in_ordnung");
expect(fristzustand(in60Tagen, 90, HEUTE)).toBe("bald_faellig");
});
it("meldet ohne Frist genau das", () => {
expect(fristzustand(null, 30, HEUTE)).toBe("ohne_frist");
expect(fristzustand(undefined, 30, HEUTE)).toBe("ohne_frist");
});
it("zählt Tage in beide Richtungen", () => {
expect(tageBis(new Date(Date.UTC(2026, 8, 13)), HEUTE)).toBe(10);
expect(tageBis(new Date(Date.UTC(2026, 7, 24)), HEUTE)).toBe(-10);
expect(tageBis(null, HEUTE)).toBeNull();
});
});
describe("Abgeleiteter Gerätestatus", () => {
it("setzt auf Prüfung fällig, wenn der Termin vorbei ist", () => {
expect(
abgeleiteterGeraetStatus({
aktuellerStatus: "EINSATZBEREIT",
naechstePruefung: new Date(Date.UTC(2026, 7, 1)),
heute: HEUTE,
}),
).toBe("PRUEFUNG_FAELLIG");
});
it("setzt zurück auf einsatzbereit, wenn der Termin wieder in der Zukunft liegt", () => {
expect(
abgeleiteterGeraetStatus({
aktuellerStatus: "PRUEFUNG_FAELLIG",
naechstePruefung: new Date(Date.UTC(2027, 0, 1)),
heute: HEUTE,
}),
).toBe("EINSATZBEREIT");
});
it("sperrt ein Gerät mit abgelaufener Lebensdauer", () => {
expect(
abgeleiteterGeraetStatus({
aktuellerStatus: "EINSATZBEREIT",
naechstePruefung: new Date(Date.UTC(2027, 0, 1)),
lebensdauerBis: new Date(Date.UTC(2026, 0, 1)),
heute: HEUTE,
}),
).toBe("GESPERRT");
});
it("lässt die Lebensdauer schwerer wiegen als eine fällige Prüfung", () => {
expect(
abgeleiteterGeraetStatus({
aktuellerStatus: "EINSATZBEREIT",
naechstePruefung: new Date(Date.UTC(2026, 0, 1)),
lebensdauerBis: new Date(Date.UTC(2026, 0, 1)),
heute: HEUTE,
}),
).toBe("GESPERRT");
});
it("fasst von Hand gesetzte Zustände nicht an", () => {
for (const status of ["GESPERRT", "AUSGESONDERT", "IN_REPARATUR"] as const) {
expect(
abgeleiteterGeraetStatus({
aktuellerStatus: status,
naechstePruefung: new Date(Date.UTC(2027, 0, 1)),
heute: HEUTE,
}),
status,
).toBe(status);
}
});
it("lässt ein Gerät ohne Fristen einsatzbereit", () => {
expect(
abgeleiteterGeraetStatus({ aktuellerStatus: "EINSATZBEREIT", heute: HEUTE }),
).toBe("EINSATZBEREIT");
});
});
describe("Vorgabe des Gerätetyps", () => {
it("lässt den Wert am Gerät gewinnen", () => {
expect(effektiverWert(6, 12)).toBe(6);
});
it("greift auf den Typ zurück, wenn am Gerät nichts steht", () => {
expect(effektiverWert(null, 12)).toBe(12);
expect(effektiverWert(undefined, 12)).toBe(12);
});
it("gibt null zurück, wenn beide leer sind", () => {
expect(effektiverWert(null, null)).toBeNull();
});
it("behandelt die 0 als gesetzten Wert, nicht als fehlend", () => {
expect(effektiverWert(0, 12)).toBe(0);
});
});
describe("Gültigkeit einer Qualifikation", () => {
it("rechnet die Gültigkeitsdauer auf das Erwerbsdatum", () => {
const bis = qualifikationGueltigBis(new Date(Date.UTC(2026, 2, 1)), 36);
expect(bis?.getUTCFullYear()).toBe(2029);
expect(bis?.getUTCMonth()).toBe(2);
});
it("gibt bei unbefristeten Qualifikationen kein Ablaufdatum zurück", () => {
expect(qualifikationGueltigBis(HEUTE, null)).toBeNull();
expect(qualifikationGueltigBis(HEUTE, 0)).toBeNull();
});
});
+8
View File
@@ -1,3 +1,4 @@
import path from "node:path";
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tsconfigPaths from "vite-tsconfig-paths"; import tsconfigPaths from "vite-tsconfig-paths";
@@ -9,6 +10,13 @@ import tsconfigPaths from "vite-tsconfig-paths";
*/ */
export default defineConfig({ export default defineConfig({
plugins: [react(), tsconfigPaths()], plugins: [react(), tsconfigPaths()],
resolve: {
alias: {
// "server-only" wirft beim blossen Import. Im Testlauf ersetzen wir es
// durch ein leeres Modul — siehe tests/stubs/server-only.ts.
"server-only": path.resolve(__dirname, "tests/stubs/server-only.ts"),
},
},
test: { test: {
environment: "node", environment: "node",
globals: true, globals: true,