Phase 1: Stammdatenverwaltung #1
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -72,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 |
|
||||||
|---|---:|
|
|---|---:|
|
||||||
@@ -89,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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,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.
|
||||||
|
|
||||||
@@ -57,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.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 201 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 414 KiB |
|
After Width: | Height: | Size: 727 KiB |
|
After Width: | Height: | Size: 746 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 132 KiB |
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
$$;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -97,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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>>(
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -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()}…`;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
@@ -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>();
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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>,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {};
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||