4 Commits
Author SHA1 Message Date
florian.wilde 3b20abcb23 Version 1.0.0 — Freigabe für den Echtbetrieb (#17)
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
2026-09-06 20:08:16 +00:00
florian.wilde 14cc4de9b8 Phase 11: Freigabereife (#16)
Pruefung / pruefung (push) Canceled after 0s
Pruefung / oberflaeche (push) Canceled after 0s
2026-09-06 20:05:49 +00:00
florian.wilde 91742a0dd7 Phase 10: Mandantenfähigkeit (#15) 2026-09-05 13:47:46 +00:00
florian.wilde 2b53556175 Phase 9: Kassenverwaltung (#14) 2026-09-05 02:30:51 +00:00
148 changed files with 19390 additions and 253 deletions
+5
View File
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
SEED_ADMIN_EMAIL=admin@beispielwehr.de
SEED_ADMIN_PASSWORT=Feuerwehr2026
# Das Konto der Kreisebene aus dem Beispielbestand (Phase 10): Mitglied in der
# eigenen Wehr, Aufsicht in der Nachbarwehr. Vorgabe leitet sich aus der
# Wehrkennung ab, also kbi@ff-musterhausen.de.
# SEED_KBI_EMAIL=
# SEED_KBI_PASSWORT=Feuerwehr2026
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
AUTH_BCRYPT_ROUNDS=12
+184
View File
@@ -0,0 +1,184 @@
# =============================================================================
# Die Pruefung bei jedem Push.
#
# Bis Phase 10 gab es 581 Unit- und 266 E2E-Tests — und nichts, was sie
# ausfuehrte ausser einem Menschen, der daran dachte. In der Sitzung, in der
# die Kassenverwaltung entstand, haben die Tests vier echte Fehler gefunden,
# zwei davon in Code, der eine Stunde alt war. Genau das soll hier von allein
# passieren.
#
# Zwei Auftraege, bewusst getrennt:
#
# pruefung typecheck, lint und die Unit-Tests. Braucht eine Datenbank,
# weil ein Teil der Tests die Trigger und RLS gegen echtes
# Postgres prueft — mit einer Attrappe waeren sie wertlos.
#
# oberflaeche die E2E-Tests. Dauern eine Dreiviertelstunde und brauchen den
# halben Stack, deshalb laufen sie nur auf main und in Pull
# Requests, nicht bei jedem Zwischenstand auf einem Zweig.
# =============================================================================
name: Pruefung
on:
push:
branches: [main]
pull_request:
env:
# Dieselben Werte wie in .env.example. Es sind Testdaten in einem
# Wegwerf-Container; echte Geheimnisse haben in einer Pipeline nichts zu
# suchen und stehen im Betrieb in der .env.
POSTGRES_USER: florian
POSTGRES_PASSWORD: pruefung
POSTGRES_DB: florian
POSTGRES_APP_PASSWORD: pruefung_app
DATABASE_URL: postgresql://florian_app:pruefung_app@localhost:5432/florian?schema=public
DIRECT_DATABASE_URL: postgresql://florian:pruefung@localhost:5432/florian?schema=public
AUTH_SECRET: nur-fuer-die-pruefung-nicht-fuer-den-betrieb
AUTH_TRUST_HOST: "true"
APP_URL: http://localhost:3100
S3_ENDPOINT: localhost
S3_PORT: "9000"
S3_USE_SSL: "false"
S3_ACCESS_KEY: florian
S3_SECRET_KEY: pruefung_minio
S3_BUCKET: florian
REDIS_URL: redis://localhost:6379
TZ: Europe/Berlin
jobs:
pruefung:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
# Das eigene Postgres-Image statt eines fertigen: FLORIAN braucht
# PostGIS **und** pgvector, und kein oeffentliches Image bringt beides
# mit. Ein Dienst-Container mit postgres:17 wuerde die Migrationen
# durchlassen und erst bei der ersten Umkreissuche scheitern.
- name: Datenbank bauen und starten
run: |
docker build -t florian-postgres:pruefung ./docker/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
florian-postgres:pruefung
- name: Redis und MinIO starten
run: |
docker run -d --name redis -p 6379:6379 redis:7-alpine
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
minio/minio:latest server /data
- run: npm ci
- name: Auf die Datenbank warten
run: |
for i in $(seq 1 60); do
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
sleep 2
done
echo "Postgres kam nicht hoch:"
docker logs postgres
exit 1
- run: npx prisma generate
- run: npm run typecheck
- run: npm run lint
- name: Migrationen einspielen
run: npm run db:migrate
# Der Seed gehoert dazu: er ist selbst ein Test. Faellt eine Migration
# oder ein Standardwert um, merkt man es hier und nicht erst, wenn eine
# Wehr ihn zum ersten Mal ausfuehrt.
- name: Beispielbestand anlegen
run: npm run db:seed
- run: npm run test
- name: Aufraeumen
if: always()
run: docker rm -f postgres redis minio || true
oberflaeche:
runs-on: ubuntu-latest
# Nur wo es sich lohnt: ein voller Lauf dauert etwa 45 Minuten.
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Dienste starten
run: |
docker build -t florian-postgres:pruefung ./docker/postgres
docker run -d --name postgres -p 5432:5432 \
-e POSTGRES_USER=$POSTGRES_USER \
-e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
-e POSTGRES_DB=$POSTGRES_DB \
-e POSTGRES_APP_PASSWORD=$POSTGRES_APP_PASSWORD \
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=de-DE --locale=C.UTF-8 --encoding=UTF8" \
-v "$(pwd)/docker/postgres:/docker-entrypoint-initdb.d:ro" \
florian-postgres:pruefung
docker run -d --name redis -p 6379:6379 redis:7-alpine
docker run -d --name minio -p 9000:9000 \
-e MINIO_ROOT_USER=$S3_ACCESS_KEY \
-e MINIO_ROOT_PASSWORD=$S3_SECRET_KEY \
minio/minio:latest server /data
docker run -d --name gotenberg -p 3001:3000 gotenberg/gotenberg:8
- run: npm ci
- run: npx playwright install --with-deps chromium
- name: Auf die Datenbank warten
run: |
for i in $(seq 1 60); do
docker exec postgres pg_isready -U "$POSTGRES_USER" -d "$POSTGRES_DB" && exit 0
sleep 2
done
docker logs postgres
exit 1
- run: npx prisma generate
- run: npm run db:migrate
# Die E2E-Tests laufen seit Phase 8 gegen eine eigene Wehr, nicht gegen
# den Beispielbestand. Das Skript legt beide an.
- name: Testwehr anlegen
run: npm run db:test-reset
- run: npm run build
- run: npx playwright test
env:
GOTENBERG_URL: http://localhost:3001
- name: Fehlerbilder sichern
if: failure()
uses: actions/upload-artifact@v4
with:
name: playwright-bericht
path: |
playwright-report/
test-results/
retention-days: 7
- name: Aufraeumen
if: always()
run: docker rm -f postgres redis minio gotenberg || true
+3
View File
@@ -40,3 +40,6 @@ debug.log
# Prisma
prisma/*.db
prisma/*.db-journal
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
sicherungen/
+454 -1
View File
@@ -6,11 +6,464 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
## [Unveröffentlicht]
## [1.0.0] — 2026-09-06
**Freigabe für den Echtbetrieb.**
Kein neues Modul. Zwölf Phasen sind fertig, und die Wehr kann ab jetzt echte
Daten hineingeben, ohne befürchten zu müssen, sie beim nächsten Update zu
verlieren.
Was 1.0.0 bedeutet: der Funktionsumfang ist vollständig, die Datenbank ist
stabil, Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
Versionierung — ein Update bricht nichts, ohne dass es in der Hauptnummer
steht.
Was es nicht bedeutet: dass keine Fehler mehr da sind.
### Hinzugefügt
- **`update.sh`** — Aktualisieren auf einem Linux-Server, in einem Befehl. Der
Ablauf darf an jeder Stelle abbrechen, ohne eine halb aktualisierte Anlage
zu hinterlassen: erst nachsehen, ob es überhaupt etwas Neues gibt, dann
**sichern**, dann holen und bauen (schlägt das fehl, läuft die alte Fassung
unverändert weiter), dann umschalten, dann nachsehen, ob die Anwendung
antwortet. Tut sie es nicht, stehen die letzten Protokollzeilen da und die
Befehle, mit denen der vorherige Stand zurückkommt.
Vorher prüft es Docker, git, die `.env` und dass keine unverbuchten
Änderungen im Verzeichnis liegen. `--pruefen` sieht nur nach,
`--ohne-sicherung` gibt es für Testinstallationen.
- **`docs/KURZANLEITUNG.md`** — der Einstieg, nach Rollen sortiert und auf ein
paar Seiten. Die vollständige Anleitung hat 63 Kapitel; die liest niemand,
bevor er das erste Gerät prüfen will. Diese hier ist aufgabenorientiert
(„ein Gerät prüfen", „einen Kontoauszug einlesen") statt nach Modulen
geordnet, und sie sagt bei jedem heiklen Punkt dazu, warum es so ist — warum
ein abgeschlossenes Prüfprotokoll nicht änderbar ist, warum gegengebucht und
nicht korrigiert wird, warum der Atemschutzhinweis stehen bleibt.
### Geändert
- Version auf **1.0.0**. Ab hier gilt semantische Versionierung nach
[docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
- Die Roadmap hält den Stand **jeder** Freigabevoraussetzung fest, auch der
offenen. Zwei sind organisatorisch und stehen aus: die Abnahme des
Löschkonzepts durch die Wehr und die Schulung. An der Software hängt nichts
davon ab; sie stehen dort, damit später nachlesbar ist, was zum Zeitpunkt
der Freigabe erledigt war und was nicht.
### Behoben
- **Die Testläufe flackerten seit Phase 8 — jetzt ist die Ursache weg.** Zwei
Datenbanktests schalteten beim Aufräumen ihre Trigger mit
`ALTER TABLE ... DISABLE TRIGGER` ab. Das ist eine Schemaänderung: sie gilt
für die ganze Datenbank und für alle. Vitest fährt die Testdateien parallel
— während die eine Datei abräumte, prüfte die andere gerade, dass genau
dieser Trigger greift, und sah keinen.
Das erklärt rückwirkend eine ganze Reihe von Fehlschlägen, die als
„Flackern" abgetan wurden und keines waren. Statt der Schemaänderung wird
jetzt `SET LOCAL session_replication_role = 'replica'` benutzt: dieselbe
Wirkung, aber nur in der eigenen Transaktion. Fünf Läufe hintereinander
sauber.
- **Ein Datenbanktest räumte nur am Ende auf.** Brach ein Lauf ab, scheiterte
der nächste an einer Eindeutigkeit, die mit dem Geprüften nichts zu tun
hatte. Er räumt jetzt auch am Anfang auf.
### Zum Stand der Freigabevoraussetzungen
| Voraussetzung | Stand |
|---|---|
| Datenübernahme | **entfällt** — die Wehr pflegt von Hand ein |
| Sicherungskonzept, einmal wiederhergestellt | **erledigt** (Phase 11) |
| Löschkonzept nach DSGVO | **technisch vollständig** (Phase 11), Abnahme steht aus |
| Schulung | **steht aus**, Grundlage ist die Kurzanleitung |
| Atemschutzhinweis | **bewusst stehen gelassen** — FLORIAN bleibt ergänzend |
---
## [0.12.0] — 2026-09-05
Phase 11: Freigabereife. Die drei Software-Voraussetzungen für 1.0.0 —
Personenauskunft nach Art. 15 DSGVO, ein geprüftes Sicherungskonzept und eine
Pipeline, die die Tests von allein ausführt.
**Das Auskunftsrecht war ein Versprechen ohne Umsetzung.** `person.export`
stand seit Phase 0 im Berechtigungskatalog und war dem Kommandanten
zugewiesen; implementiert war nichts. Die Löschseite gab es (Anonymisierung im
Aufbewahrungslauf), die Auskunftsseite nicht — und ohne Art. 15 ist ein
Löschkonzept nicht abnehmbar.
**Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.** Genau
das hat sich hier bestätigt: der erste Rückspielversuch lief scheinbar
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
Datenbank entfernt.
### Hinzugefügt
**Auskunft nach Art. 15 DSGVO**
- `lib/dsgvo/auskunft.ts` sammelt alles, was über eine Person vorliegt:
Stammdaten, Zugang, Rollen, Qualifikationen, arbeitsmedizinische Vorsorge,
Belastungsübungen, Einsatz- und Übungsbeteiligung, Anwesenheiten, Fahrten,
Beiträge, Benachrichtigungswege, Ausweise. Dreizehn Abschnitte.
- **Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger und den Hinweis
auf die weiteren Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und
ist keine.
- **Gesundheitsdaten sind als besondere Kategorie nach Art. 9 gekennzeichnet** —
G26 und Belastungsübungen.
- **Leere Abschnitte bleiben stehen.** „Dazu liegt nichts vor" ist eine
Auskunft; ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas
ist.
- **Was die Person getan hat, wird gezählt, nicht mitgeliefert.** Prüfprotokolle
und Mängelmeldungen gehören zum Nachweisbestand der Wehr. Vollständig
ausgeliefert käme jeder über eine Selbstauskunft an die Geräteakten — die
Auskunftspflicht wäre eine Hintertür.
- Ein Durchgang liefert alles: die Anzeige, ein PDF für die Akte und JSON nach
Art. 20. Bewusst nicht drei Knöpfe, von denen zwei vergessen werden können.
- Der Vorgang wird protokolliert. Dass eine Auskunft erteilt wurde, gehört
festgehalten — sie legt alles offen, was über einen Menschen gespeichert ist.
**Sicherung und Wiederherstellung**
- `npm run sicherung` — Datenbank über `pg_dump` im Container, dazu alle
Dateien aus MinIO, dazu ein Manifest. Beides aus demselben Moment: eine
Datenbank von heute mit Dateien von gestern ergibt Verweise ins Leere.
- `npm run wiederherstellen` — mit Probelauf als Vorgabe. Ohne `--wirklich`
wird nur angezeigt, was geschähe.
- Nach dem Zurückspielen setzt das Skript die Sperren der Anwendungsrolle neu
und **prüft danach nach**, ob es gewirkt hat.
- `scripts/sicherung-sperren.ts` ist die eine Liste, aus der Wiederherstellen
und Prüfen entstehen. Ein Test gleicht sie gegen die migrierte Datenbank ab —
läuft sie auseinander, wäre der Schaden lautlos.
**Pipeline**
- `.gitea/workflows/pruefung.yml` mit zwei Aufträgen: `pruefung` (typecheck,
lint, Migrationen, Seed, 599 Unit-Tests) bei jedem Push, `oberflaeche`
(E2E) auf `main` und in Pull Requests.
- Die Datenbank wird aus `docker/postgres` gebaut statt aus einem fertigen
Image: FLORIAN braucht PostGIS **und** pgvector. Ein Dienst-Container mit
`postgres:17` ließe die Migrationen durch und scheiterte erst bei der ersten
Umkreissuche.
- Der Seed läuft mit, weil er selbst ein Test ist.
### Behoben
- **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien.** Der
Dump lief mit `--no-acl`, die Rechte kamen also nicht zurück; und selbst
ohne dieses Flag greift beim Zurückspielen die Vorgabeprivilegierung aus
`docker/postgres/10-init-app-role.sh` — jede von `pg_restore` neu angelegte
Tabelle bekommt automatisch alle Rechte, und `GRANT` kann nur hinzufügen,
nie wegnehmen. Nach einer Wiederherstellung durfte die Anwendungsrolle das
Audit-Log ändern, Wiki-Fassungen löschen und Kiosk-Vorgänge umschreiben.
Nichts hätte darauf hingewiesen.
Gefunden haben es vier Datenbanktests unmittelbar nach der ersten
Rückspielprobe — also genau die Probe, wegen der 1.0.0 sie verlangt.
- **Ein E2E-Test klickte, bevor die Seite lebendig war.** Unter Last dauert das
Hydrieren länger; das Formular verschickte sich dann selbst, und die
Fehlerfläche, auf die der Test wartete, entstand nie — obwohl der Server
richtig ablehnte. Er wartet jetzt auf einen client-seitig erzeugten Knopf.
### Nachweise
- Sicherung und Wiederherstellung einmal vollständig durchgeführt: ein nach
der Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten
wieder, und **599 Tests liefen nach dem Zurückspielen grün** — einschließlich
derer, die die Sperren prüfen.
---
## [0.11.0] — 2026-09-05
Phase 10: Mandantenfähigkeit. Ein Konto, mehrere Wehren, eine Kreisebene, die
zusieht — und eine Verwaltung für den, der die Installation betreibt.
**Das Anmeldekonto ist von der Personenakte gelöst.** Bis hierher war die Akte
zugleich das Konto: Passwort-Hash und OIDC-Subject hingen an `person`, und
`person` gehört genau einer Wehr. Ein Mensch konnte damit nicht zu zwei Wehren
gehören, ohne zwei Passwörter zu haben — und die Anmeldung per E-Mail war
mehrdeutig, sobald dieselbe Adresse in zwei Wehren stand, weil die
Eindeutigkeit nur je Organisation galt.
**Das Konto trägt keine Mandantenkennung — und kann keine tragen.** Es ist der
Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. Geschützt wird es
deshalb wie `Berechtigung` aus Phase 0: über Rechte statt über Row Level
Security. Was ein Konto in einer Wehr darf, entscheidet nicht es selbst,
sondern seine `Mitgliedschaft` — und die trägt `organisationId` wie jede
andere Tabelle.
**Die Kreisebene sieht und ändert nichts.** Ihre Rechte werden nicht
aufgezählt, sondern gefiltert: alles, was `.lesen` heißt, und sonst nichts.
Eine Liste von Hand wäre bei der nächsten Phase veraltet, und zwar unbemerkt.
### Hinzugefügt
**Datenmodell**
- `Benutzer` — das Anmeldekonto. Global eindeutige E-Mail, ein Passwort, ein
OIDC-Subject. Ohne `organisationId`, mit Begründung im Schema.
- `Mitgliedschaft` — die Zugehörigkeit eines Kontos zu einer Wehr, als
`MITGLIED` (mit Personenakte) oder `AUFSICHT` (ohne).
- `Person` verliert die Anmeldefelder. Der Passwort-Hash kann damit aus einer
Personenabfrage gar nicht mehr herausfallen — die drei Stellen, die ihn
bisher vorsichtshalber abgeschnitten haben, sind entfallen.
**Was die Datenbank durchsetzt** (Migrationen `20260905090000` und
`20260905091500`)
- RLS für `mitgliedschaft` wie für jede andere Tabelle. Eine Wehr sieht damit
auch nicht, in welchen anderen ein Konto noch geführt wird.
- Trigger `mitgliedschaft_akte_gleiche_wehr` — eine Zuordnung, deren
Personenakte in einer anderen Wehr liegt, wird abgelehnt. Ein Fremdschlüssel
prüft nur, *dass* es die Akte gibt, nicht *wo* sie liegt; ohne den Trigger
ließe sich ein Konto über eine fremde Akte in eine Wehr hängen.
- Trigger `benutzer_kennung_unveraenderlich` — E-Mail, OIDC-Subject und das
Verwaltermerkmal lassen sich nicht aus einer Wehr heraus ändern.
- `REVOKE INSERT, DELETE ON benutzer` für die Anwendungsrolle.
- CHECKs: `MITGLIED` braucht eine Akte, `AUFSICHT` darf keine haben; ein Konto
braucht einen Anmeldeweg; eine E-Mail ohne @ ist keine.
**Die Umstellung ist datenerhaltend.** Jede Person mit Anmeldedaten bekommt
ein Konto und eine Mitgliedschaft, bevor die alten Spalten fallen. Wer schon
mit derselben Adresse in zwei Wehren stand, bekommt **ein** Konto mit **zwei**
Mitgliedschaften — genau das, was vorher nicht möglich war. Eine Prüfung am
Ende bricht die Migration ab, statt einen halben Zustand zu hinterlassen.
**Oberfläche**
- **Wehrwähler** in der Seitenleiste. Er erscheint nur bei mehr als einer Wehr
— ein Auswahlfeld mit genau einem Eintrag ist eine Frage ohne
Antwortmöglichkeit.
- Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute.
- Ein dauerhafter Hinweis, wenn man eine Wehr als Kreisaufsicht sieht — nicht
als kurz aufblitzende Meldung.
- **`/wehren`** für den Verwalter: Wehren anlegen (samt Systemrollen), Konten
führen, Zuordnungen vergeben und beenden. Jede Zuordnung wird in der
betroffenen Wehr protokolliert, nicht beim Verwalter — sonst hätte der
Betreiber einen Weg, sich still in fremde Daten zu setzen.
**Rechte**
- Neue Rolle `AUFSICHT` — bewusst **nicht** im Prisma-Enum: sie wird nicht
vergeben, sondern ergibt sich aus der Mitgliedschaftsart.
- Ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden: `kasse.lesen` (die
Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht),
`person.export`, `system.auditlog`, `ki.protokoll`.
- `istVerwalter` als Merkmal des Kontos, nicht als Recht.
**Tests**
- 19 neue Unit-Tests, davon 11 gegen eine echte Datenbank: Doppelmitgliedschaft,
der Trigger gegen wehrfremde Akten, die CHECKs und die Trennung der
Zuordnungen über RLS.
- 13 E2E-Tests: kein Wähler bei einer Wehr, Wechsel in die betreute und
zurück, die Mitglieder der anderen Wehr, die verschlossene Kasse, und dass
der Administrator die Umstellung überlebt hat.
**Seed**
- Eine zweite, kleine Wehr je Mandant und ein Konto, das in der einen Mitglied
und in der anderen Kreisaufsicht ist. Ohne sie ließe sich die Phase nicht
ausprobieren — und eine Mandantentrennung, die nie zwei Mandanten hatte, ist
keine geprüfte.
### Geändert
- Eine Sitzung ohne Personenakte ist gültig. Bis hierher galt genau das als
„nicht angemeldet"; für die Kreisaufsicht, die in der betreuten Wehr keine
Akte hat, hätte jeder Aufruf auf der Anmeldeseite geendet.
- `Sitzung.personId` ist nullbar. Der Typechecker hat damit die fünf Stellen
gezeigt, an denen eine Aufsichtssitzung nichts verloren hat — Anmeldemarke,
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz.
- Die Mitgliederansicht zeigt statt eines ungenutzten Zeitstempels, ob jemand
überhaupt ein Konto hat. Das ist die nützlichere Auskunft: ohne Konto kommt
er nicht in FLORIAN hinein.
### Behoben
- **Ein Testaufbau war nicht wiederholbar.** `ki-datenbank.test.ts` legte Wehr
und Person idempotent an, die Wiki-Seite aber nicht — blieb aus einem
abgebrochenen Lauf etwas stehen, scheiterte der ganze Datei-Lauf an einer
Eindeutigkeit, die mit dem Geprüften nichts zu tun hat.
- **`db:test-reset` ließ die Nachbarwehr stehen** und der Seed hielt daraufhin
alles für erledigt — die Mitgliedschaft in der neu angelegten Testwehr
entstand nie wieder. Der Reset nimmt sie jetzt mit, und der Seed prüft
Nachbarwehr und Kreiskonto getrennt statt mit einem gemeinsamen Ausstieg.
- **Der Fehlversuch-Test sperrte das gemeinsame Testkonto.** Fünf Fehlversuche
sperren ein Konto für fünf Minuten; bei zwei Projekten und Wiederholungen
kam das im vollen Lauf zusammen, und fremde Testdateien fielen mit
„Anmeldung fehlgeschlagen“ aus. Er nimmt jetzt eine Adresse, die es nicht
gibt — die Meldung muss ohnehin dieselbe sein.
---
## [0.10.0] — 2026-09-04
Phase 9: Kassenverwaltung. Kassenbuch, Mitgliedsbeiträge, Rechnungen an
Dritte, Kassenprüfung und die Auswertungen für die Jahreshauptversammlung.
**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im
Router es so vorsieht, sondern weil die Datenbank es ablehnt. Korrigiert wird
über eine Gegenbuchung mit Verweis; beide Buchungen bleiben stehen, und im
Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein
Kassenprüfer sehen — eine verschwundene Buchung dagegen kann er nicht prüfen.
**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung
mit einem Datum darin mehr angenommen, auch keine nachträglich hineindatierte.
Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach einem
Datumswechsel nicht mehr — und niemand hätte es bemerkt.
**Gerechnet wird mit `Prisma.Decimal`, nirgends mit Fließkomma.** `0.1 + 0.2`
ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das kein
Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand erklären
kann.
### Hinzugefügt
**Datenmodell**
- `Kassenkonto` (bar, Bank, Rücklage) mit Anfangsbestand. Der laufende Bestand
wird **gerechnet und nie gespeichert**: ein gespeicherter Bestand neben
einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
- `Buchungskategorie` als zweistufiger Baum, mit Kontonummer für den Export an
die Kämmerei.
- `Buchung` mit Datum, Richtung, immer positivem Betrag, Verwendungszweck,
Belegnummer, Beleg-Datei und dem Verweis auf die Gegenbuchung.
- `Beitragssatz` und `Beitrag` (offen, bezahlt, erlassen, gemahnt) mit
Mahnstufe und Erlassbegründung.
- `Rechnung` und `Rechnungsposition` mit Nummernkreis je Jahr. Die Menge hat
drei Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet.
- `Kassenabschluss` und `Abschlussposten` mit Bestand laut Buch, gezähltem
Bestand, Differenz, zwei Prüfern und dem Bericht als PDF.
**Was die Datenbank durchsetzt** (Migration `20260904140000`)
- RLS für alle neun Tabellen, wie in jeder Phase zuvor.
- Trigger `buchung_unveraenderlich` — festgeschriebene Buchungen lassen sich
weder ändern noch löschen. Änderbar bleibt genau eines: der Verweis auf die
Gegenbuchung. Eine Stornierung entsteht später; sie einzutragen ist keine
Änderung des Vorgangs, sondern seine Fortschreibung.
- Trigger `buchung_zeitraum_offen` — ein geprüfter Zeitraum nimmt weder eine
neue Buchung an noch das Hineindatieren einer vorhandenen.
- Trigger `abschluss_unveraenderlich` — ein festgeschriebener Abschluss bleibt,
wie er ist. Nachtragen lässt sich nur der Prüfbericht, denn das PDF entsteht
im Hintergrund und damit nach dem Festschreiben.
- `REVOKE DELETE ON kassenabschluss` für die Anwendung.
- CHECKs, die verhindern, was sich hinterher nicht mehr erkennen ließe: ein
Betrag von null oder darunter, eine Buchung an eine Person **und** einen
Dritten zugleich, ein erlassener Beitrag ohne Begründung, ein Abschluss mit
einer Differenz, die nicht zu seinen eigenen Zahlen passt, eine Rechnung,
die fällig wird, bevor sie geschrieben wurde.
**Kassenbuch**
- Buchungsmaske mit **Kategorievorschlag**: zuerst aus den eigenen früheren
Buchungen der Wehr, erst danach aus einer kurzen Stichwortliste. Unter der
Hälfte gemeinsamer Wörter schweigt der Vorschlag — ein falscher wird
übernommen, ohne gelesen zu werden.
- Summen über **alle** Treffer, nicht nur über die angezeigte Seite.
- Festschreiben als Sammelaktion bis zu einem Stichtag.
- Gegenbuchen mit Pflichtbegründung; das Buchungsdatum ist immer heute, damit
ein geprüfter Zeitraum zubleibt.
- **Aus einem gegengezeichneten Beleg wird eine Buchung.** Die Brücke zu
Phase 8: fotografiert und ausgefüllt wird am Terminal oder am Handy,
gebucht wird in der Kasse — mit Konto und Kategorie, die keine Maschine
entscheiden kann. Das Foto hängt danach an der Buchung, und genau das
verlangt eine Kassenprüfung: zu jeder Ausgabe ein Beleg, an der Buchung
und nicht in einem Ordner daneben. Ein Entwurf lässt sich nicht buchen.
**Kontoauszug einlesen**
- CAMT.053 und MT940, beides ohne zusätzliche Abhängigkeit. Erkannt wird am
Inhalt, nicht an der Dateiendung.
- Es entstehen **Entwürfe, keine fertigen Buchungen**.
- Jeder Umsatz trägt eine Kennung — die der Bank, sonst eine aus Datum, Betrag
und Zweck gebildete. Ein zweimal eingelesener Auszug wird dadurch als solcher
erkannt, statt einen Monat doppelt zu buchen.
- Was nicht gelesen werden konnte, steht mit Grund daneben, statt still zu
verschwinden.
**Beiträge**
- Sätze je Abteilung, gültig ab einem Jahr und nicht rückwirkend änderbar.
- Beitragslauf mit **Vorschau**: wie viele Beiträge entstehen, welche Summe —
und wer keinen passenden Satz hat. Der Lauf ist wiederholbar, ohne dass
etwas doppelt entsteht.
- Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der
Kasse wäre eine Behauptung.
- Mahnen als Sammelaktion, Erlassen nur mit Begründung.
- Rückstandsliste über alle Jahre — die Ansicht, in der auffällt, wer seit
drei Jahren offen steht.
**Rechnungen**
- Fortlaufende Nummer je Jahr, lückenlos: eine fehlende Nummer muss in der
Kassenprüfung erklärt werden.
- Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben.
- Bis zum Versenden ein Entwurf, danach unveränderbar. Korrigiert wird über
Storno und eine neue Rechnung — die stornierte bleibt mit Grund stehen.
- PDF über Gotenberg, im Hintergrund erzeugt.
**Kassenprüfung und Auswertung**
- Prüfmaske: links der Bestand laut Buch, rechts der gezählte. In dieser
Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis es passt.
- Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben; eine Differenz
ohne Erläuterung ebenso wenig.
- Kassenprüfbericht als PDF mit vier Unterschriftsfeldern.
- Jahresauswertung nach Kategorie und Monat, mit Vorjahresvergleich.
- CSV-Export mit Semikolon und BOM — was Excel in Deutschland ohne Rückfrage
richtig öffnet.
**Tests**
- 67 neue Unit-Tests: Geldrechnen, beide Auszugsformate, Kategorievorschlag,
und dreizehn gegen eine echte Datenbank, die die Trigger und CHECKs prüfen.
Sie laufen über den Systemzugang: kommt nicht einmal der Eigentümer der
Datenbank daran vorbei, kommt niemand vorbei.
- 13 E2E-Tests über Desktop und Tablet.
**Seed**
- Ein vollständiges Kassenjahr: drei Konten, der Standardkontenrahmen,
24 Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen, zwei
Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres. Die Zahlen
des Abschlusses sind die Summe der Buchungen und nicht erfunden.
### Geändert
- Ein E2E-Test aus Phase 0 sicherte zu, dass die Kasse in der Navigation
**kein** Verweis ist — sie war ja noch nicht da. Die Zusicherung ist mit
dieser Phase abgelaufen und prüft jetzt das Gegenteil. Ein künftiges Modul
zum Ausgrauen gibt es nicht mehr; kommt später eines dazu, gehört sie
zurück.
### Behoben
- **Die Rechnungsnummer war ein Wettlauf.** Höchste lesen und dann schreiben:
schreiben zwei Leute gleichzeitig eine Rechnung, lesen beide dieselbe
höchste Nummer, und die zweite läuft in den Eindeutigkeitsfehler. Gefunden
hat das der parallele E2E-Lauf. Der seltene Zusammenstoß wird jetzt erkannt
und die nächste Nummer genommen — lückenlos bleibt sie dabei.
- **Eine Organisation ließ sich nicht mehr löschen**, sobald sie eine
festgeschriebene Buchung hatte: der Trigger lehnte auch das Löschen im
Aufbewahrungslauf ab. Er kennt jetzt denselben Schalter wie das Audit-Log
und das Prüfprotokoll (`app.audit_bereinigung`). Ohne ihn wäre die
DSGVO-Löschung einer ausscheidenden Wehr nicht durchführbar gewesen.
---
## [0.9.1] — 2026-09-04
Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen
angesammelt hatten und in jeder Phase Zeit gekostet haben.
### Hinzugefügt
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
+457 -2
View File
@@ -1,8 +1,14 @@
# FLORIAN — Feuerwehr-Verwaltungsplattform
**Version 1.0.0** — freigegeben für den Echtbetrieb.
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
> **Wer FLORIAN benutzen und nicht bauen will:**
> [**Kurzanleitung**](docs/KURZANLEITUNG.md) — alles Wichtige nach Rollen
> sortiert, auf ein paar Seiten.
## Schnellstart
```bash
@@ -32,7 +38,9 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
| Phase 6 — Kartenmodule | fertig |
| Phase 7 — Benachrichtigungen und Termine | fertig |
| Phase 8 — Lokale KI | fertig |
| Phase 9 — Kassenverwaltung | offen |
| Phase 9 — Kassenverwaltung | fertig |
| Phase 10 — Mandantenfähigkeit | fertig |
| Phase 11 — Freigabereife | fertig |
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
@@ -41,7 +49,8 @@ Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
| Datei | Inhalt |
|---|---|
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Bedienung mit Bildschirmfotos |
| [docs/KURZANLEITUNG.md](docs/KURZANLEITUNG.md) | **Der Einstieg.** Nach Rollen sortiert, ein paar Seiten |
| [docs/ANLEITUNG.md](docs/ANLEITUNG.md) | Die vollständige Bedienung mit Bildschirmfotos, zum Nachschlagen |
| [docs/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
@@ -1155,9 +1164,431 @@ Fehlermeldung der Datenbank durchzureichen, die niemand versteht.
---
## Phase 9 — Kassenverwaltung
Kassenbuch, Mitgliedsbeiträge, Rechnungen an Dritte, Kassenprüfung und die
Zahlen für die Jahreshauptversammlung.
Eine Feuerwehrkasse wird von einem Ehrenamtlichen geführt und von zwei anderen
geprüft. Was die Prüfung wert ist, hängt einzig daran, dass sich das Geprüfte
hinterher nicht mehr ändern lässt. Deshalb ist der zentrale Punkt dieser Phase
kein Formular, sondern eine Sperre.
### Drei Festlegungen
**Eine festgeschriebene Buchung ist unveränderbar.** Nicht, weil ein Zweig im
Router es so vorsieht, sondern weil die Datenbank es ablehnt — ein vergessener
Zustandscheck, ein Skript, ein Import würden sonst reichen. Korrigiert wird
über eine **Gegenbuchung** mit Verweis: beide Buchungen bleiben stehen, und im
Kassenbuch ist zu sehen, dass etwas berichtigt wurde. Genau das will ein
Kassenprüfer sehen; eine verschwundene Buchung kann er nicht prüfen.
**Der Kassenabschluss schließt seinen Zeitraum.** Danach wird keine Buchung
mit einem Datum darin mehr angenommen — auch keine nachträglich
hineindatierte. Ohne diese zweite Hälfte stimmte der geprüfte Bestand nach
einem Datumswechsel nicht mehr, und niemand hätte es bemerkt. Wer nach der
Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum.
**Beträge sind `DECIMAL(12,2)`, gerechnet wird mit `Prisma.Decimal`.**
`0.1 + 0.2` ergibt in JavaScript 0.30000000000000004. In einer Kasse ist das
kein Rundungsfehler, sondern eine Differenz in der Prüfung, die niemand
erklären kann.
### Das Kassenbuch
Drei Kontenarten: Barkasse, Bankkonto, Rücklage. Der **Bestand wird gerechnet,
nie gespeichert** — Anfangsbestand plus die Wirkung aller Buchungen. Ein
gespeicherter Bestand neben einer nachgetragenen Buchung wäre falsch, ohne
falsch auszusehen, und genau danach sucht ein Kassenprüfer.
Der Betrag einer Buchung ist immer positiv; die Richtung steht daneben. Eine
negative Ausgabe wäre eine Einnahme in Verkleidung und in keiner Auswertung
als solche zu erkennen — die Datenbank nimmt sie deshalb nicht an.
Die Buchungsmaske schlägt eine Kategorie vor: zuerst aus den **eigenen
früheren Buchungen** der Wehr, erst danach aus einer kurzen Stichwortliste.
Der Nutzen liegt nicht in den gesparten Sekunden, sondern darin, dass dieselbe
Sache immer unter derselben Kategorie landet — erst das macht die
Jahresauswertung brauchbar. Unter der Hälfte gemeinsamer Wörter schweigt der
Vorschlag: ein falscher wird übernommen, ohne gelesen zu werden.
Ein **gegengezeichneter Beleg** aus Phase 8 wird mit einem Klick zur Buchung:
Konto und Kategorie wählen, fertig. Betrag, Datum und Händler stehen schon
fest, und das Foto hängt danach an der Buchung — zu jeder Ausgabe ein Beleg,
an der Buchung und nicht in einem Ordner daneben. Ein Entwurf lässt sich
nicht buchen: er ist ein von einem Modell ausgefülltes Formular.
### Kontoauszug einlesen
Das Online-Banking bietet beides zum Herunterladen an, meist unter
„Umsatzexport“:
| Format | Datei | Was gelesen wird |
|---|---|---|
| CAMT.053 | `.xml`, `.camt` | Datum, Richtung, Betrag, Verwendungszweck, Gegenseite, Bankkennung |
| MT940 | `.txt`, `.sta` | dasselbe, aus den Feldern `:61:` und `:86:` |
Erkannt wird am Inhalt, nicht an der Dateiendung. Es entstehen **Entwürfe,
keine fertigen Buchungen** — durchsehen muss sie der Kassier.
Jeder Umsatz trägt eine Kennung: die der Bank, sonst eine aus Datum, Betrag
und Zweck gebildete. Derselbe Auszug zweimal eingelesen wird dadurch als
solcher erkannt und in der Liste als „steht schon in der Kasse“ ausgewiesen.
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
Kassenabschluss auf.
Was nicht gelesen werden konnte, steht mit Grund daneben. Ein Parser, der
stillschweigend drei Umsätze verschluckt, ist schlimmer als einer, der gar
nicht erst liest.
### Mitgliedsbeiträge
Sätze gelten ab einem Jahr und je Abteilung; ein Satz ohne Abteilung gilt für
alle übrigen. Rückwirkend geändert wird nichts — sonst stimmten die Beiträge
vergangener Jahre nicht mehr, und die sind bereits geprüft.
Der **Beitragslauf** zeigt vorher, was entstehen wird: wie viele Beiträge,
welche Summe, und wer keinen passenden Satz hat. Der letzte Teil ist der
interessante — das sind die Leute, für die niemand entschieden hat, was sie
zahlen. Der Lauf ist wiederholbar; im Februar noch einmal gestartet legt er
nur die Neuen an.
Ein Zahlungseingang erzeugt zugleich die Buchung. Ein Häkchen ohne Geld in der
Kasse wäre eine Behauptung. Ein Erlass braucht eine Begründung — die Datenbank
lehnt einen ohne ab, weil er in der Kassenprüfung sonst eine offene Frage ist.
Die Rückstandsliste geht über alle Jahre. Sie ist die einzige Ansicht, in der
auffällt, wer seit drei Jahren offen steht.
### Rechnungen an Dritte
Kostenersatz nach Art. 28 BayFwG, Vermietungen, Veranstaltungen. Die Nummer
vergibt FLORIAN fortlaufend je Jahr und **lückenlos**: eine fehlende Nummer
muss in der Kassenprüfung erklärt werden.
Der Betrag ist die Summe der Positionen, gerechnet und nicht eingegeben. Ein
eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben, ist der
Fehler, den niemand bemerkt, bis der Empfänger anruft. Die Menge hat drei
Nachkommastellen — Einsatzstunden werden viertelstündlich abgerechnet, und
0,25 × 47,50 muss auf den Cent stimmen.
Bis zum Versenden ist eine Rechnung ein Entwurf, danach unveränderbar: was
beim Empfänger liegt, muss dem entsprechen, was hier steht. Korrigiert wird
über Storno und eine neue Rechnung; die stornierte bleibt mit Grund stehen.
### Kassenprüfung
In der Prüfmaske steht links der Bestand laut Buch und rechts der gezählte.
In dieser Reihenfolge, und die gezählten Felder fangen leer an — wer erst das
Buch sieht und dann zählt, zählt, bis es passt.
Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es
kein Abschluss, sondern eine Notiz. Eine Differenz braucht eine Erläuterung,
sonst steht in fünf Jahren „stimmt nicht“ im Bericht und niemand weiß mehr,
warum.
Der Kassenprüfbericht entsteht als PDF mit vier Unterschriftsfeldern und wird
im Hintergrund erzeugt.
### Auswertungen
Einnahmen und Ausgaben nach Kategorie und Monat, mit dem Vorjahr daneben —
eine Zahl ohne Vergleich sagt nichts. Der Export ist eine CSV-Datei mit
Semikolon und BOM: das ist, was Excel in Deutschland ohne Rückfrage richtig
öffnet. Eine Datei, die beim Doppelklick als eine Spalte voller Semikolons
erscheint, wird zurückgeschickt.
### Wer was sieht
| Recht | Wer | Was |
|---|---|---|
| `kasse.lesen` | Kassier, Kommandant, Vorstand | Buchungen und Auswertungen ansehen |
| `kasse.bearbeiten` | Kassier | Buchungen, Beiträge, Rechnungen erfassen |
| `kasse.abschluss` | Kassier, Kassenprüfer | Kassenabschluss anlegen und festschreiben |
Der Gerätewart sieht keine Buchungen, der Kassier keine Atemschutzdaten. Die
Trennung liegt in `lib/authz.ts`, nicht in den Komponenten.
**Keine zusätzlichen Umgebungsvariablen.** Die Kasse braucht nur, was ohnehin
läuft: Postgres, MinIO für die Belege und Gotenberg für die PDFs.
---
## Phase 10 — Mandantenfähigkeit
Ein Server, mehrere Wehren. Ein Konto, mehrere Zugehörigkeiten. Eine
Kreisebene, die zusieht.
Datenmodell und Row Level Security tragen seit Phase 0 mehrere Wehren — jede
Tabelle hat `organisationId`, jede Abfrage läuft im Mandantenkontext. Was
fehlte, war alles darüber: eine Anmeldung, die zu mehr als einer Wehr gehören
kann, ein Weg zum Umschalten, und eine Verwaltung für den, der den Server
betreibt.
### Konto und Akte sind nicht dasselbe
Bis Phase 9 war die Personenakte zugleich das Anmeldekonto: Passwort-Hash und
OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr. Daraus
folgten zwei Dinge, die niemand wollte:
- Wer in zwei Wehren geführt wurde, brauchte **zwei Passwörter**, die
auseinanderlaufen können.
- Die Anmeldung per E-Mail war **mehrdeutig**, sobald dieselbe Adresse in zwei
Wehren stand — die Eindeutigkeit galt nur je Organisation, und gefunden
wurde, was die Datenbank zuerst hergab.
Seit Phase 10:
| Tabelle | Was sie ist | Mandant |
|---|---|---|
| `Benutzer` | das Anmeldekonto — E-Mail, Passwort, OIDC | **keiner** |
| `Mitgliedschaft` | Zugehörigkeit eines Kontos zu einer Wehr | `organisationId` |
| `Person` | die Akte in einer Wehr — Dienstgrad, Qualifikationen, Einsätze | `organisationId` |
**`Benutzer` trägt bewusst keine Mandantenkennung** und kann keine tragen: sie
ist ja der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift
dort deshalb nicht. Geschützt wird sie stattdessen über Rechte — dieselbe
Lösung wie bei `Berechtigung` aus Phase 0 und aus demselben Grund: eine
Tabelle, die über allen Mandanten steht, gehört nicht in die Hand der
Anwendungsrolle. Was ein Konto in einer Wehr darf, entscheidet nicht es
selbst, sondern seine `Mitgliedschaft`.
Ein angenehmer Nebeneffekt: der Passwort-Hash kann aus einer Personenabfrage
gar nicht mehr herausfallen. Die drei Stellen, die ihn bisher vorsichtshalber
abgeschnitten haben, sind entfallen — eine Sorgfaltsmaßnahme ist durch eine
Struktur ersetzt.
### Die Wehr wechseln
Der Wähler steht in der Seitenleiste unter dem Namen der Wehr — **aber nur,
wenn es etwas zu wählen gibt.** Für die allermeisten gibt es das nie, und ein
Auswahlfeld mit genau einem Eintrag ist keine Hilfe, sondern eine Frage ohne
Antwortmöglichkeit.
Nach der Anmeldung öffnet die **eigene** Wehr, nicht die betreute: wer eine
Wehr führt und acht andere beaufsichtigt, will in seiner landen.
Technisch: die Wehr steht im Sitzungstoken, also wird beim Wechsel ein neues
ausgestellt. Was der Browser dabei schickt, ist ein **Wunsch** — geprüft wird
serverseitig gegen die Mitgliedschaften des Kontos. Eine fremde Kennung zu
nennen bringt nichts, weil es dazu keine Mitgliedschaft gibt.
### Die Kreisebene
Kreisbrandrat und Kreisbrandinspektion gehören den betreuten Wehren nicht an.
Sie bekommen deshalb dort **keine Personenakte**, tauchen in keiner
Mitgliederliste auf und können nichts ändern.
Ihre Rechte werden nicht aufgezählt, sondern **gefiltert**: alles, was
`.lesen` heißt, und sonst nichts. Eine von Hand gepflegte Liste wäre bei der
nächsten Phase veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte
mit, die niemand nachträgt. So bekommt die Aufsicht neue Leserechte
automatisch und neue Schreibrechte nie.
Vier Rechte sind ausdrücklich ausgenommen, obwohl sie auf `.lesen` enden:
| Recht | Warum nicht |
|---|---|
| `kasse.lesen` | Die Kasse gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
| `person.export` | Das Auskunftsrecht nach DSGVO erfüllt die Wehr, die die Daten führt. |
| `system.auditlog` | Wer wann was getan hat, bleibt innerhalb der Wehr. |
| `ki.protokoll` | Dasselbe. |
Der **Atemschutz** steht dagegen bewusst drin: ob ein Träger tauglich ist, ist
genau die Frage, für die es eine Kreisebene gibt.
Wer als Aufsicht unterwegs ist, sieht dauerhaft einen Hinweis darauf — nicht
als kurz aufblitzende Meldung, sondern als Zeile über der Ansicht. Man soll
nicht erst merken, in welcher Rolle man ist, wenn ein Knopf fehlt.
### Doppelmitgliedschaft
Ein Konto, mehrere Zugehörigkeiten. In Bayern der Regelfall bei allem, was
über die eigene Wehr hinausgeht: aktiv in A, Ehrenmitglied in B, oder
Kommandant der einen und Kreisbrandinspektor für sechs weitere.
Zwei Zusicherungen setzt die Datenbank durch:
- **Ein Konto gehört einer Wehr höchstens einmal an.**
- **Eine Personenakte hängt an höchstens einem Konto.** Sonst wäre nicht
entscheidbar, wer unter ihrem Namen gehandelt hat.
Und eine dritte, die weniger offensichtlich ist: die Akte muss zu **derselben
Wehr** gehören wie die Zuordnung. Ein Fremdschlüssel prüft nur, *dass* es die
Akte gibt, nicht *wo* sie liegt — ohne diese Prüfung ließe sich ein Konto über
eine Akte aus Wehr A in Wehr B hängen, und die Sitzung stünde dort mit einer
Personenkennung da, die niemandem gehört. Ein Trigger lehnt das ab.
### Wehren und Konten verwalten
Unter **`/wehren`**, und nur für den, der die Installation betreibt.
Der Zugang hängt an einem Merkmal des Kontos (`istVerwalter`), **nicht an
einem Recht**. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein
Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre genau die
Hintertür, die diese Phase vermeiden soll.
Was dort geht:
- Eine Wehr anlegen — sie bekommt sofort ihre Systemrollen samt Rechten. Ein
Konto bekommt sie nicht: eine Wehr anzulegen und jemanden hineinzusetzen
sind zwei Entscheidungen, und die zweite trifft meist jemand anders.
- Ein Konto anlegen — ohne Wehr. Die kommt über die Zuordnung.
- Zuordnen: als Mitglied mit Personenakte oder als Kreisaufsicht ohne.
**Jede Zuordnung wird in der betroffenen Wehr protokolliert**, nicht beim
Verwalter. Sonst hätte der Betreiber einen Weg, sich still in fremde Daten zu
setzen.
**Keine zusätzlichen Umgebungsvariablen.**
### Was bewusst fehlt
**Wehrübergreifende Auswertungen.** Eine Zahl über mehrere Wehren hinweg
braucht eine abgestimmte Definition — was zählt als Einsatz, was als Übung,
wie wird eine Doppelmitgliedschaft gezählt. Ohne die ist eine solche Zahl
schlimmer als keine, weil sie benutzt wird. Die Aufsicht sieht jede Wehr
einzeln, und das reicht für die Fragen, die tatsächlich gestellt werden.
**Ein Einladungsverfahren.** Passwörter werden vom Verwalter vergeben und
weitergegeben. FLORIAN verschickt keine.
---
## Phase 11 — Freigabereife
Die drei Dinge, die zwischen dem letzten Modul und dem Echtbetrieb standen:
das Auskunftsrecht, ein geprüftes Sicherungskonzept und eine Pipeline.
### Auskunft nach Art. 15 DSGVO
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
werden. In einer Feuerwehr ist das mehr, als die meisten erwarten — und mit
den G26-Untersuchungen sind Gesundheitsdaten dabei, also eine besondere
Kategorie nach Art. 9.
Auf der Seite eines Mitglieds steht dafür der Knopf **Datenauskunft**
(Recht `person.export`). Ein Durchgang liefert drei Dinge:
| | Wofür |
|---|---|
| **Anzeige** | Was ausgehändigt wird, vor dem Aushändigen |
| **PDF** | Das Schreiben an die Person, für die Akte |
| **JSON** | Datenübertragbarkeit nach Art. 20 |
Bewusst nicht drei Knöpfe: wer eine Auskunft erteilt, soll sie vollständig
erteilen und nicht in Schritten, von denen zwei vergessen werden können.
**Über jedem Abschnitt steht, wozu die Daten vorliegen.** Art. 15 Abs. 1
verlangt nicht nur die Daten, sondern auch Zwecke, Empfänger, Speicherdauer
und den Hinweis auf die weiteren Rechte. Eine Liste ohne das sieht aus wie
eine Auskunft und ist keine.
Zwei Festlegungen bestimmen den Zuschnitt:
**Was über die Person gespeichert ist, steht vollständig drin** — auch das
Unangenehme: eine nicht bestandene G26, ein gemahnter Beitrag. Eine Auskunft,
die auswählt, was gut aussieht, ist keine.
**Was die Person getan hat, steht als Zahl drin.** Wer zwölf Prüfprotokolle
unterschrieben hat, bekommt die Zahl — nicht zwölf Protokolle. Die gehören zum
Nachweisbestand der Wehr; sie mitzuliefern hieße, dass jeder über eine
Selbstauskunft an die Geräteakten käme. Die Trennlinie ist nicht „viel oder
wenig", sondern „über mich" gegen „von mir".
Leere Abschnitte bleiben stehen. „Dazu liegt nichts vor" ist eine Auskunft;
ein weggelassener Abschnitt wirft die Frage auf, ob da doch etwas ist.
Der Vorgang wird protokolliert — dass eine Auskunft erteilt wurde, gehört
festgehalten.
### Sicherung
```bash
npm run sicherung # nach ./sicherungen/<Zeitstempel>/
npm run sicherung -- --ziel D:\backups
```
Gesichert wird beides, aus **demselben Moment**: die Datenbank über `pg_dump`
im Container und alle Dateien aus MinIO. Eine Datenbank von heute mit Dateien
von gestern ergibt Datensätze, die auf nichts zeigen.
Daneben entsteht ein `manifest.json` mit Zeitpunkt, Version und Umfang. Es
wird beim Zurückspielen angezeigt, damit niemand versehentlich eine Sicherung
von vor drei Monaten einspielt, weil das Verzeichnis ähnlich hieß.
### Wiederherstellung
```bash
npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
npm run wiederherstellen -- --quelle ... --wirklich
```
Ohne `--wirklich` passiert nichts — es wird nur angezeigt, was geschähe. Das
ist keine Bequemlichkeit: das Skript überschreibt den gesamten Bestand.
**Der eigentliche Zweck ist nicht der Notfall, sondern die Probe.** Eine
Sicherung, die nie zurückgespielt wurde, ist eine Vermutung: der Ordner kann
leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es an dem
Tag, an dem man es nicht erfahren will.
Wie berechtigt das ist, hat sich hier gezeigt. Der erste Rückspielversuch lief
sauber durch — und hatte dabei **sämtliche Unveränderlichkeitsgarantien der
Datenbank entfernt**. Der Grund liegt tiefer, als man vermutet:
`docker/postgres/10-init-app-role.sh` richtet eine Vorgabeprivilegierung ein,
damit neue Tabellen aus Migrationen automatisch nutzbar sind. `pg_restore`
legt die Tabellen aber ebenfalls neu an — die Vorgabe greift also auch dort,
und die `GRANT`-Zeilen aus dem Dump können nur hinzufügen, nie wegnehmen. Die
Anwendungsrolle durfte danach das Audit-Log ändern, Wiki-Fassungen löschen und
Kiosk-Vorgänge umschreiben. Nichts an der Anwendung hätte darauf hingewiesen.
Deshalb setzt das Skript die Sperren nach dem Zurückspielen neu und **prüft
danach nach**, ob es gewirkt hat. Die Liste dafür steht in
`scripts/sicherung-sperren.ts`, und ein Test gleicht sie gegen die migrierte
Datenbank ab: läuft sie auseinander, käme eine Sperre nach einer
Wiederherstellung nicht zurück, von der niemand mehr weiß, dass es sie gab.
Nach dem Zurückspielen bleiben drei Handgriffe, die keine Software abnehmen
kann:
1. Anmelden — kommt jemand hinein?
2. Ein Prüfprotokoll öffnen — ist das PDF da?
3. Die Mitgliederzahl mit der Erwartung vergleichen.
Erst danach gilt die Sicherung als geprüft.
### Die Pipeline
`.gitea/workflows/pruefung.yml`, zwei Aufträge:
| Auftrag | Was | Wann |
|---|---|---|
| `pruefung` | typecheck, lint, Migrationen, Seed, 599 Unit-Tests | jeder Push und Pull Request |
| `oberflaeche` | die E2E-Tests über Chromium | nur `main` und Pull Requests |
Die Trennung ist eine Zeitfrage: der erste Auftrag dauert Minuten, der zweite
eine Dreiviertelstunde.
Die Datenbank wird aus `docker/postgres` **gebaut** statt aus einem fertigen
Image genommen. FLORIAN braucht PostGIS und pgvector, und kein öffentliches
Image bringt beides mit; ein Dienst-Container mit `postgres:17` ließe die
Migrationen durchlaufen und scheiterte erst bei der ersten Umkreissuche. Der
Seed läuft mit, weil er selbst ein Test ist: fällt ein Standardwert um, merkt
man es hier und nicht, wenn eine Wehr ihn zum ersten Mal ausführt.
> **Voraussetzung: ein registrierter Runner.** Die Datei allein genügt nicht —
> Gitea braucht einen `act_runner`, der die Aufträge annimmt. Ist keiner
> eingerichtet, erscheinen die Läufe als „wartend" und laufen nie. Zum
> Zeitpunkt dieser Phase war noch keiner registriert; der Ablauf ist also
> geschrieben, aber nicht im Betrieb erprobt.
---
## Befehle
```bash
npm run sicherung # Datenbank und Dateien sichern
npm run wiederherstellen # zurueckspielen (Probelauf ohne --wirklich)
npm run dev # Entwicklungsserver auf Port 3100
npm run build # Produktionsbuild
npm run worker:dev # Hintergrundprozess mit Neuladen
@@ -1231,6 +1662,30 @@ ohne Traefik. In Produktion terminiert Traefik TLS und verteilt auf
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker
tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
### Aktualisieren
```bash
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
./update.sh # einspielen
```
Der Ablauf ist so gebaut, dass er an jeder Stelle abbrechen darf, ohne eine
halb aktualisierte Anlage zu hinterlassen:
1. Nachsehen, ob überhaupt etwas Neues da ist — sonst endet es hier.
2. **Sichern.** Vor allem anderen; ein Update ist der häufigste Anlass, eine
Sicherung zu brauchen.
3. Neue Fassung holen und die Abbilder bauen. Schlägt das fehl, läuft die alte
Fassung unverändert weiter — es wurde noch nichts umgeschaltet.
4. Umschalten. Der App-Container spielt dabei die Migrationen ein.
5. Nachsehen, ob die Anwendung wieder antwortet. Tut sie es nach zweieinhalb
Minuten nicht, stehen die letzten Protokollzeilen da und die Befehle, mit
denen der vorherige Stand zurückkommt.
Vorher prüft das Skript, dass Docker, git und die `.env` da sind und dass im
Verzeichnis keine unverbuchten Änderungen liegen. `--ohne-sicherung` gibt es
für Testinstallationen; im Betrieb gehört es nicht benutzt.
## Datenschutz
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
+547 -12
View File
@@ -1,9 +1,11 @@
# Anleitung
Bedienung von FLORIAN, Stand Phase 8 (Version 0.9.0). Die Bilder zeigen die
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
Bedienung von FLORIAN, Version 1.0.0. Die Bilder zeigen die Beispielwehr aus
dem Seed und werden mit `npm run screenshots` neu erzeugt.
> Die Kassenverwaltung kommt in Phase 9 dazu. Siehe [ROADMAP.md](ROADMAP.md).
> **Das hier ist zum Nachschlagen, nicht zum Durchlesen.** Wer anfangen will,
> nimmt die [Kurzanleitung](KURZANLEITUNG.md) — nach Rollen sortiert, auf ein
> paar Seiten.
---
@@ -1680,9 +1682,9 @@ Prüfen Sie gegen das Foto, das links daneben steht. Dann **Gegenzeichnen**.
Ohne Vision-Modell funktioniert alles bis auf das Auslesen: der Beleg wird
fotografiert und abgelegt, die Angaben tragen Sie von Hand ein.
Die Buchung daraus kommt mit der Kasse in Phase 9. Hier entsteht erst einmal ein
sauber erfasster Beleg mit Foto — und der ist schon mehr wert als ein
Schuhkarton.
Ist der Beleg gegengezeichnet, steht in der Liste der erledigten Belege ein
Knopf **Buchen**. Er führt in einem Schritt ins Kassenbuch — zu wählen sind
nur Konto und Kategorie. Siehe [Kapitel 48](#48-buchen).
### Das KI-Protokoll
@@ -1704,7 +1706,422 @@ Datensatz, seit dem ersten Tag.
---
## 47. Rollen und was sie dürfen
## 47. Die Kasse
Unter **Kasse** stehen alle Konten der Wehr mit ihrem heutigen Bestand.
![Kassenübersicht](screenshots/45-kasse-light.png)
Drei Arten von Konten:
| Art | Wofür |
|---|---|
| **Barkasse** | Die Kassette im Gerätehaus. Wechselgeld für Feste, Kleinausgaben. |
| **Bankkonto** | Das Girokonto der Wehr. Mit IBAN, damit sich Kontoauszüge zuordnen lassen. |
| **Rücklage** | Angespartes für eine Beschaffung. Wird nicht laufend bewegt. |
**Der Bestand wird gerechnet, nicht gespeichert.** Anfangsbestand plus alle
Buchungen. Wer nachträglich eine Buchung einträgt, sieht den Bestand sofort
richtig — es gibt keinen zweiten Wert, der danebenläuft.
### Beim ersten Mal
Steht die Kasse leer, bietet die Übersicht an, den **Standardkontenrahmen**
anzulegen: rund zwanzig Kategorien, wie sie eine Feuerwehrkasse braucht —
Kraftstoff, Reparatur, Schutzkleidung, Ausbildung, Verpflegung auf der einen
Seite; Zuschüsse, Spenden, Mitgliedsbeiträge, Veranstaltungen, Kostenersatz
auf der anderen.
Sie lassen sich danach umbenennen, ergänzen und um eigene Unterkategorien
erweitern. Wer eine eigene Gliederung hat, legt sie stattdessen von Hand an.
---
## 48. Buchen
![Kassenbuch](screenshots/46-kassenbuch-light.png)
Oben stehen drei Summen: Einnahmen, Ausgaben, Saldo. Sie beziehen sich auf
**alle** Treffer der eingestellten Auswahl, nicht nur auf die angezeigten
Zeilen — eine Summe unter einer abgeschnittenen Liste bedeutete nichts.
### Eine Buchung erfassen
Der Betrag ist immer positiv; ob es eine Einnahme oder eine Ausgabe ist, steht
mit den beiden Knöpfen daneben. Eine „negative Ausgabe" gibt es nicht: sie
wäre eine Einnahme in Verkleidung und in keiner Auswertung als solche zu
erkennen.
Sobald ein Verwendungszweck dasteht, schlägt FLORIAN eine Kategorie vor.
Zuerst aus den **eigenen früheren Buchungen** der Wehr — steht darunter „zuletzt
so gebucht", ist dieselbe Sache schon einmal so einsortiert worden. Erst wenn
es dazu nichts gibt, greift eine Stichwortliste.
Der Vorschlag ist ein Angebot, kein Automatismus: er wird erst übernommen,
wenn er angeklickt wird. Und ist er unsicher, erscheint gar keiner. Ein
falscher Vorschlag wäre schlechter als keiner — er wird übernommen, ohne
gelesen zu werden.
### Entwurf und festgeschrieben
Eine neue Buchung ist zunächst ein **Entwurf**. Sie lässt sich ändern und
verwerfen, solange der Kassier sie durchsieht.
Mit **Festschreiben** wird sie unveränderbar. Das ist der Zustand, den eine
Kassenprüfung erwartet, und dahinter führt kein Weg zurück — auch nicht über
einen Umweg, die Datenbank lehnt jede Änderung ab.
Ein Schloss in der Zeile zeigt, welche Buchungen festgeschrieben sind.
### Einen Beleg buchen
Wurde eine Quittung unter **Belege** fotografiert und gegengezeichnet, muss
sie nicht noch einmal abgetippt werden. In der Liste der erledigten Belege
steht dort ein Knopf **Buchen**.
Zu wählen sind nur Konto und Kategorie — alles andere steht schon fest.
Betrag, Datum und Händler kommen aus dem gegengezeichneten Beleg, und das
Foto hängt danach an der Buchung. Das ist der Zustand, den eine
Kassenprüfung erwartet: zu jeder Ausgabe ein Beleg, und zwar an der Buchung
und nicht in einem Ordner daneben.
Ein Beleg, der noch nicht gegengezeichnet ist, lässt sich nicht buchen. Er
ist bis dahin ein von einer Maschine ausgefülltes Formular.
### Wenn etwas falsch war
Bei einem Entwurf: ändern oder verwerfen.
Bei einer festgeschriebenen Buchung: **Gegenbuchen**. Es entsteht eine zweite
Buchung mit derselben Summe in die andere Richtung, mit Verweis auf die erste
und mit dem eingegebenen Grund im Verwendungszweck. Beide bleiben stehen.
Das sieht nach mehr Aufwand aus, als eine Zahl zu korrigieren, und ist genau
der Punkt: eine verschwundene Buchung kann ein Kassenprüfer nicht prüfen. Eine
berichtigte kann er nachvollziehen.
Das Datum der Gegenbuchung ist immer **heute**. Läge es im geprüften Zeitraum,
würde die Datenbank sie ablehnen — und das zu Recht.
---
## 49. Kontoauszug einlesen
Statt fünfzig Umsätze abzutippen, lässt sich der Auszug aus dem Online-Banking
einlesen. Zwei Formate:
| Format | Datei |
|---|---|
| **CAMT.053** | `.xml` oder `.camt` |
| **MT940** | `.txt` oder `.sta` |
Beides bietet fast jedes Online-Banking zum Herunterladen an, meist unter
„Umsatzexport" oder „Umsätze exportieren". Welches von beiden, spielt keine
Rolle — FLORIAN erkennt es am Inhalt.
### Was dabei passiert — und was nicht
Es entstehen **Entwürfe**, keine fertigen Buchungen. Jeder Posten muss danach
noch durchgesehen und die Kategorie geprüft werden.
Umsätze, die schon in der Kasse stehen, sind grau hinterlegt und mit „steht
schon in der Kasse" gekennzeichnet. Sie sind nicht vorausgewählt. Das ist der
eigentliche Wert der Sache: ein zweimal eingelesener Auszug wäre sonst ein
doppelt gebuchter Monat, und der fällt erst beim Kassenabschluss auf — also
drei Monate später.
Umsätze, die nicht gelesen werden konnten, stehen mit Grund darüber. Sie
verschwinden nicht stillschweigend.
---
## 50. Mitgliedsbeiträge
![Beiträge](screenshots/47-beitraege-light.png)
### Sätze
Ein Beitragssatz gilt **ab einem Jahr** und wahlweise für eine Abteilung. Ein
Satz ohne Abteilung gilt für alle, die keinen eigenen haben.
Rückwirkend wird nichts geändert. Wer den Beitrag erhöht, legt einen neuen
Satz ab dem kommenden Jahr an — sonst stimmten die Beiträge der vergangenen
Jahre nicht mehr, und die sind bereits geprüft.
### Der Beitragslauf
Einmal im Jahr, meist im Januar. Vorher zeigt FLORIAN, was entstehen wird:
- wie viele Beiträge und über welche Summe
- wie viele Mitglieder für dieses Jahr schon einen haben (die bleiben unberührt)
- **wer keinen passenden Satz hat**
Der letzte Punkt ist der wichtige. Das sind die Mitglieder, für die niemand
entschieden hat, was sie zahlen — entweder fehlt ein Satz für ihre Abteilung
oder es gibt keinen allgemeinen.
Der Lauf lässt sich wiederholen. Wer im Februar drei Neue aufgenommen hat,
startet ihn noch einmal; doppelt entsteht nichts.
### Zahlungen und Mahnungen
Ein Zahlungseingang erzeugt zugleich die **Buchung** im Kassenbuch. Ein
Häkchen ohne Geld in der Kasse wäre eine Behauptung.
Gemahnt wird als Sammelaktion: die offenen Beiträge auswählen, mahnen. Die
Mahnstufe zählt hoch. Ab der fünften Stufe hört FLORIAN auf — was danach
kommt, entscheidet die Kommandantschaft und nicht die Software.
Ein **Erlass** braucht eine Begründung. Ohne sie wird er nicht gespeichert:
ein erlassener Beitrag ohne Grund ist in der Kassenprüfung eine offene Frage,
und in fünf Jahren weiß es niemand mehr.
### Rückstände
Ganz oben steht die Rückstandsliste über **alle Jahre**, nach Person
gruppiert. Sie ist die einzige Ansicht, in der auffällt, wer seit drei Jahren
offen steht — in der Jahresliste sieht das aus wie ein einzelner offener
Beitrag.
---
## 51. Rechnungen an Dritte
![Rechnungen](screenshots/48-rechnungen-light.png)
Kostenersatz nach Art. 28 BayFwG (Fehlalarm einer Brandmeldeanlage, Öleinsatz
mit Verursacher), Vermietung des Schulungsraums, Absicherung einer
Veranstaltung.
### Nummer und Betrag
Die **Rechnungsnummer** vergibt FLORIAN fortlaufend je Jahr, lückenlos. Eine
fehlende Nummer muss in der Kassenprüfung erklärt werden, deshalb wird auch
eine stornierte Rechnung nicht gelöscht, sondern bleibt mit Grund stehen.
Der **Betrag** ist die Summe der Positionen und wird nicht eingegeben. Ein von
Hand eingetippter Gesamtbetrag neben Positionen, die etwas anderes ergeben,
ist der Fehler, den niemand bemerkt, bis der Empfänger anruft.
Die Menge hat drei Nachkommastellen: Einsatzstunden werden vielerorts
viertelstündlich abgerechnet, und 0,25 × 47,50 € muss auf den Cent stimmen.
### Entwurf, versendet, bezahlt
Bis zum **Versenden** ist eine Rechnung ein Entwurf und lässt sich vollständig
ändern. Danach nicht mehr: was beim Empfänger liegt, muss dem entsprechen, was
hier steht.
Beim Versenden entsteht das PDF im Hintergrund. Es steht kurz darauf zum
Herunterladen bereit.
Der Zahlungseingang erzeugt — wie beim Beitrag — zugleich die Buchung. Wurde
weniger überwiesen als berechnet, lässt sich der abweichende Betrag eintragen.
Für eine Korrektur: stornieren und neu schreiben. Eine bezahlte Rechnung wird
nicht storniert; dort ist zuerst die Buchung gegenzubuchen.
---
## 52. Kassenprüfung
![Kassenabschluss](screenshots/49-kassenabschluss-light.png)
Einmal im Jahr, meist vor der Jahreshauptversammlung. Zwei Kassenprüfer zählen
die Kasse und vergleichen mit dem Buch.
### Der Ablauf
1. **Zeitraum wählen.** In der Regel das abgelaufene Kalenderjahr.
2. **Zählen.** In der Maske stehen links die Bestände laut Buch und rechts
leere Felder für das Gezählte. Die Reihenfolge ist Absicht: wer erst das
Buch sieht und dann zählt, zählt, bis es passt.
3. **Differenz erklären**, falls eine bleibt.
4. **Prüfer und Prüfdatum eintragen.**
5. **Festschreiben.**
Steht ein Hinweis auf noch nicht festgeschriebene Buchungen im Zeitraum: erst
durchsehen. Das Festschreiben des Abschlusses nimmt sie mit.
### Was das Festschreiben bewirkt
Der Zeitraum ist danach **zu**. Es lässt sich keine Buchung mehr mit einem
Datum darin anlegen, und keine vorhandene mehr dorthin verschieben. Wer nach
der Prüfung noch eine Quittung findet, bucht sie mit dem heutigen Datum.
Der Abschluss selbst ist ebenfalls unveränderbar — Zahlen, Prüfer und
Erläuterung stehen fest. Nachgetragen wird nur der Prüfbericht, weil das PDF
im Hintergrund entsteht.
Ohne Prüfer und Prüfdatum lässt sich nichts festschreiben: ohne beides ist es
kein Abschluss, sondern eine Notiz. Und eine Differenz ohne Erläuterung
ebenfalls nicht — sonst steht in fünf Jahren „stimmt nicht" im Bericht und
niemand weiß mehr, warum.
### Der Prüfbericht
Entsteht als PDF mit vier Unterschriftsfeldern: beide Prüfer, der Kassier und
der Kommandant. Er lässt sich ausdrucken, unterschreiben und abheften — die
Papierform bleibt für die Entlastung in der Versammlung die maßgebliche.
---
## 53. Auswertungen und Export
Unter **Kasse → Abschluss → Auswertung** stehen die Zahlen für die
Jahreshauptversammlung:
- Einnahmen und Ausgaben je Kategorie, absteigend nach Betrag
- der Verlauf über zwölf Monate
- das Vorjahr daneben — eine Zahl ohne Vergleich sagt nichts
Das ist die Ansicht, in der auffällt, dass sich die Kraftstoffkosten verdoppelt
haben.
### Export
**Kassenbuch als CSV** lädt alle Buchungen eines Jahres herunter: Datum,
Konto, Richtung, Betrag, Ober- und Unterkategorie, Kontonummer,
Verwendungszweck, Belegnummer, Gegenüber und ob festgeschrieben.
Die Datei ist für den Steuerberater oder die Kämmerei gedacht und öffnet in
Excel ohne Rückfrage richtig: Semikolon als Trennzeichen, Komma als
Dezimaltrennzeichen, UTF-8 mit Kennung.
---
## 54. Mehrere Wehren auf einem Server
Ein FLORIAN kann mehrere Wehren führen — eine Gemeinde mit drei Ortswehren,
ein Landkreis mit zwanzig. Die Wehren sehen einander dabei **nicht**. Jede
Abfrage ist an eine Wehr gebunden, und zwar in der Datenbank selbst, nicht im
Programm.
### Konto und Personenakte
Zwei verschiedene Dinge, und der Unterschied ist der Kern dieses Kapitels:
| | Was es ist |
|---|---|
| **Konto** | Womit Sie sich anmelden. E-Mail und Passwort. Gehört keiner Wehr. |
| **Personenakte** | Ihr Eintrag in einer Wehr: Dienstgrad, Qualifikationen, Einsätze. |
| **Zuordnung** | Verbindet beides — je Wehr eine. |
Wer nur zu einer Wehr gehört, merkt davon nichts: ein Konto, eine Akte, eine
Zuordnung, und alles sieht aus wie vorher.
Wer zu **zwei** Wehren gehört — aktiv in der einen, Ehrenmitglied in der
anderen —, hat trotzdem nur **ein** Passwort. Zwei Akten, ein Konto.
### Die Wehr wechseln
Steht Ihr Konto bei mehreren Wehren, wird der Name der Wehr in der
Seitenleiste zu einem Auswahlfeld. Ein Klick darauf, und alle Wehren stehen
untereinander — mit dem Hinweis, ob Sie dort Mitglied oder Kreisaufsicht sind.
Nach der Anmeldung sind Sie immer in **Ihrer eigenen** Wehr, nicht in einer
betreuten.
Nach dem Wechsel wird alles neu geladen. Was Sie danach sehen, gehört zur
neuen Wehr — es gibt keinen Zustand, in dem Daten der einen unter dem Namen
der anderen stehen.
---
## 55. Kreisbrandrat und Kreisbrandinspektion
Die Kreisebene bekommt Zugang zu den betreuten Wehren, ohne ihnen anzugehören.
**Sie sieht und ändert nichts.** Das ist keine Einstellung, die jemand
versehentlich anders setzen kann: eine Aufsichtszuordnung bekommt genau die
Leserechte und keines der Schreibrechte, und zwar automatisch auch für Module,
die es noch gar nicht gibt.
Wer als Aufsicht in einer Wehr unterwegs ist, sieht das durchgehend als Zeile
über der Ansicht.
### Was die Aufsicht sieht
Mitglieder, Fahrzeuge, Geräte, Prüfungen, Atemschutz, Einsätze, Übungen,
Qualifikationen, Termine, Wiki, Karte.
Der **Atemschutz** ist dabei der wichtigste Punkt: ob ein Träger tauglich ist,
ist genau die Frage, für die es eine Kreisebene gibt.
### Was sie nicht sieht
| | Warum |
|---|---|
| **Kasse** | Sie gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. |
| **Protokoll** | Wer wann was getan hat, bleibt innerhalb der Wehr. |
| **KI-Protokoll** | Dasselbe. |
| **Personenexport nach DSGVO** | Das Auskunftsrecht erfüllt die Wehr, die die Daten führt. |
### Sie taucht in keiner Mitgliederliste auf
Die Aufsicht hat in der betreuten Wehr **keine Personenakte**. Sie ist also
kein Mitglied, erscheint in keiner Liste, in keiner Stärkemeldung und in
keiner Anwesenheit — und kann als Urheber einer Änderung auch gar nicht
eingetragen werden, weil sie keine macht.
Ist der Kreisbrandinspektor zugleich Kommandant seiner Heimatwehr, hat er dort
ganz normal seine Akte und seine Rollen. Es sind zwei Zuordnungen desselben
Kontos, mehr nicht.
---
## 56. Wehren und Konten verwalten
Unter **Wehren und Konten**. Der Menüpunkt erscheint nur für den, der die
Installation betreibt — nicht für Kommandanten, auch nicht für
Administratoren einer einzelnen Wehr.
> Dieses Merkmal hebt die Trennung zwischen den Wehren auf. Es gehört an so
> wenige Konten wie möglich, im Regelfall an eines.
### Eine Wehr anlegen
Vollständiger Name, Kurzname und eine **Kennung**. Die Kennung steht später
im Terminal und in Dateipfaden und lässt sich nicht mehr ändern — also mit
Bedacht wählen: Kleinbuchstaben, Ziffern, Bindestriche, etwa
`ff-musterhausen`.
Die neue Wehr bekommt sofort ihre Rollen samt Rechten. Ein Konto bekommt sie
nicht: das ist der zweite Schritt.
### Ein Konto anlegen
E-Mail-Adresse und ein erstes Passwort. Die Adresse gilt über alle Wehren
hinweg und ist damit die Anmeldung.
Das Passwort steht im Klartext da, damit Sie es weitergeben können — FLORIAN
verschickt keines. Es gehört nach der ersten Anmeldung geändert.
Gibt es zu der Adresse schon ein Konto, sagt FLORIAN das und legt **kein
zweites** an. Wenn diese Person zu einer weiteren Wehr gehören soll, bekommt
sie eine weitere Zuordnung — genau dafür ist die Trennung da.
### Zuordnen
Konto wählen, **Wehr zuordnen**, dann:
- **Mitglied** — zusätzlich die Personenakte in dieser Wehr wählen. Zur
Auswahl steht, wer noch kein Konto hat: eine Akte gehört zu höchstens einem,
sonst wäre nicht entscheidbar, wer unter ihrem Namen gehandelt hat.
- **Kreisaufsicht** — keine Akte, nur Leserechte.
Jede Zuordnung wird **in der betroffenen Wehr** protokolliert. Dort steht also
nachlesbar, dass jemand von außen Zugang bekommen hat.
### Eine Zuordnung beenden
Über das Papierkorb-Symbol. Die Personenakte in der Wehr bleibt bestehen —
entfernt wird nur der Zugang. Wer ausgetreten ist, wird in der Mitgliederliste
ausgetragen, nicht hier.
---
## 57. Rollen und was sie dürfen
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
@@ -1716,10 +2133,11 @@ kann sie also nachträglich zuschneiden, ohne dass jemand Code ändert.
| **Kommandant** | alles innerhalb der eigenen Wehr, einschließlich Rollen vergeben und Protokoll einsehen |
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
| **Kassier** | Buchungen und Kassenabschluss (Modul in Phase 9), Belege erfassen und gegenzeichnen |
| **Kassier** | Kassenbuch, Beiträge, Rechnungen und Kassenabschluss; Belege erfassen und gegenzeichnen |
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
| **Mitglied** | eigene Daten, Listen lesen, Termine sehen und sich zurückmelden, eigene Benachrichtigungswege einrichten, im Wiki lesen |
| **Kreisebene** | sieht die betreuten Wehren, ändert nichts — siehe [Kapitel 55](#55-kreisbrandrat-und-kreisbrandinspektion) |
| **Terminal** | nur anmelden und erfassen — nie verwalten |
Zwei Dinge sind bewusst so gebaut:
@@ -1738,7 +2156,7 @@ sind nur die Textvorlagen, und die hängen an einem eigenen Recht.
---
## 48. Anmeldung über authentik einrichten
## 58. Anmeldung über authentik einrichten
authentik läuft **getrennt** von FLORIAN und ist nicht Teil dieses Stacks.
@@ -1791,7 +2209,7 @@ ganze Wehr aus.
---
## 49. PIN und Ausweiskarte verwalten
## 59. PIN und Ausweiskarte verwalten
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
was dahinter liegt.
@@ -1814,7 +2232,7 @@ Huber), PIN `471203`.
---
## 50. Was protokolliert wird
## 60. Was protokolliert wird
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
@@ -1839,7 +2257,124 @@ jeweiligen Detailseite sichtbar.
---
## 51. Wenn etwas nicht geht
## 61. Wenn jemand Auskunft über seine Daten verlangt
Jede Person darf verlangen zu erfahren, welche Daten über sie verarbeitet
werden — Art. 15 DSGVO. Die Anfrage muss **innerhalb eines Monats** beantwortet
werden, und sie ist kostenlos.
### So geht es
1. **Mitglieder** öffnen, die Person anklicken.
2. Rechts oben auf **Datenauskunft**.
3. **Auskunft erstellen**.
Danach stehen drei Dinge bereit:
| | Wofür |
|---|---|
| **Die Anzeige** | Was ausgehändigt wird — vor dem Aushändigen |
| **PDF** | Das Schreiben an die Person. Ausdrucken oder als Datei geben |
| **JSON** | Nur, wenn die Person die Daten maschinenlesbar verlangt (Art. 20) |
Das Recht dazu heißt `person.export` und liegt bei Kommandant und
Administrator.
### Was drinsteht
Dreizehn Abschnitte: Stammdaten, Zugang, Rollen, Qualifikationen,
arbeitsmedizinische Vorsorge, Belastungsübungen, Einsatz- und
Übungsbeteiligung, Anwesenheiten, Fahrten, Beiträge, Benachrichtigungswege,
Ausweise.
Über jedem steht, **wozu** diese Daten vorliegen. Das ist kein Beiwerk: Art. 15
verlangt neben den Daten auch die Zwecke und den Hinweis auf die weiteren
Rechte. Eine bloße Liste erfüllt die Auskunftspflicht nicht.
**Auch das Unangenehme steht drin** — ein gemahnter Beitrag, eine nicht
bestandene G26. Eine Auskunft, die auswählt, was gut aussieht, ist keine.
**Leere Abschnitte bleiben stehen.** „Dazu liegen keine Daten vor" ist eine
Auskunft.
**Gesundheitsdaten sind gekennzeichnet.** G26 und Belastungsübungen sind eine
besondere Kategorie nach Art. 9 — das steht im Bericht ausdrücklich dabei.
### Was nicht drinsteht
Was die Person **getan** hat, erscheint nur als Zahl: „12 durchgeführte
Prüfungen", „3 gemeldete Mängel".
Das ist Absicht. Die Prüfprotokolle selbst gehören zum Nachweisbestand der
Feuerwehr und nicht zu den personenbezogenen Daten eines Mitglieds. Würden sie
mitgeliefert, käme jeder über eine Selbstauskunft an die kompletten
Geräteakten — die Auskunftspflicht wäre eine Hintertür.
### Der Vorgang wird protokolliert
Dass eine Auskunft erteilt wurde, steht danach unter **Protokoll**. Das ist
richtig so: die Auskunft legt alles offen, was über einen Menschen gespeichert
ist, einschließlich seiner Gesundheitsdaten. Wer sie erstellt hat und wann,
gehört festgehalten.
### Wenn die Person in zwei Wehren geführt wird
Dann gibt es **zwei** Auskünfte, eine je Wehr. Das ist kein Versehen: jede Wehr
ist eine eigene Verantwortliche im Sinne der Verordnung und kann nur über die
Daten Auskunft geben, die sie selbst führt.
---
## 62. Sicherung und Wiederherstellung
> **Der wichtigste Satz dieses Kapitels:** Eine Sicherung, die nie
> zurückgespielt wurde, ist eine Vermutung.
### Sichern
```bash
npm run sicherung
npm run sicherung -- --ziel D:\backups
```
Gesichert wird die Datenbank **und** der Dateispeicher — Prüfprotokolle,
Einsatzberichte, Fotos, Rechnungen. Beides aus demselben Moment: eine
Datenbank von heute mit Dateien von gestern ergibt Einträge, die auf nichts
zeigen.
Das Ergebnis ist ein Verzeichnis mit Zeitstempel. Es gehört auf ein anderes
Gerät — eine Sicherung auf derselben Festplatte hilft gegen einen
Festplattenfehler nicht.
### Zurückspielen
```bash
npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-16-48
```
So passiert **nichts**. Es wird nur angezeigt, aus welchem Moment die
Sicherung stammt und wie groß sie ist. Erst mit `--wirklich` am Ende wird
zurückgespielt — und dabei **der gesamte vorhandene Bestand überschrieben**.
### Einmal im Jahr proben
Nicht im Notfall zum ersten Mal. Der Ordner kann leer sein, der Auszug
abgeschnitten, das Passwort falsch — man erfährt es sonst an dem Tag, an dem
man es nicht erfahren will.
Nach dem Zurückspielen prüft das Skript selbst nach, ob die Schutzsperren der
Datenbank wieder stehen. Drei Handgriffe bleiben trotzdem:
1. **Anmelden** — kommt jemand hinein?
2. **Ein Prüfprotokoll öffnen** — ist das PDF da?
3. **Die Mitgliederzahl** mit der Erwartung vergleichen.
Erst danach gilt die Sicherung als geprüft. Das Datum der letzten geglückten
Probe gehört ins Sicherungskonzept.
---
## 63. Wenn etwas nicht geht
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|---|---|---|
+39 -2
View File
@@ -39,8 +39,10 @@ Faktoren, die in die Schätzung eingehen:
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
| 7 | Benachrichtigungen und Termine | 10–13 PT | 2,5–3,5 h | **3:10 h** |
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
| | **Summe** | **123–164 PT** | **28–37 h** | |
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
| 10 | Mandantenfähigkeit | 4–6 PT | 1–1,5 h | **4:20 h** |
| 11 | Freigabereife | 5–7 PT | 1,5–2 h | **2:40 h** |
| | **Summe** | **132–177 PT** | **30,5–40,5 h** | **35:30 h** |
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
@@ -422,6 +424,41 @@ Die Unveränderlichkeit festgeschriebener Buchungen kostet hier fast nichts —
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
übernehmen.
**Tatsächlich: 4:10 h** — die längste Phase bisher. Beide Schätzungen oben
haben sich bestätigt und zugleich verschoben: MT940 kostete weniger als
gedacht, weil beide Formate ohne Fremdbibliothek auskommen. Dafür war die
Unveränderlichkeit nicht das Übernehmen eines Musters, sondern drei
Trigger mit drei genau begründeten Ausnahmen — und die Ausnahmen sind der
Teil, der Zeit gekostet hat. Zwei davon sind erst im Test aufgefallen:
der Prüfbericht, der nach dem Festschreiben entsteht, und die
DSGVO-Löschung einer Wehr, die an ihren eigenen festgeschriebenen Buchungen
scheiterte.
## Phase 10 — Mandantenfähigkeit
**Vergleichsaufwand: 4–6 PT** — aus der Kandidatenliste vorgezogen, am
05.09.2026 umgesetzt.
| Baustein | PT |
|---|---:|
| `Benutzer` und `Mitgliedschaft`, datenerhaltende Umstellung | 1,5 |
| Anmeldung: Konto statt Akte, Wehrwechsel im Sitzungstoken | 1,5 |
| Kreisebene mit reinen Leserechten | 1,0 |
| Wehrwähler und Verwaltung der Installation | 1,5 |
Die Schätzung ging von „technisch bereits möglich, Oberfläche fehlt" aus —
und das war zur Hälfte falsch. Datenmodell und RLS trugen tatsächlich mehrere
Wehren, aber die Anmeldung nicht: Passwort und OIDC-Subject hingen an der
Personenakte, und die gehört genau einer Wehr. Das war kein Oberflächenthema,
sondern ein Umbau am Fundament.
**Tatsächlich: 4:20 h.** Getragen hat eine Entscheidung ganz am Anfang: die
Sitzung behält ihre Form und entsteht nur anders. Ohne sie wären 61 Dateien
mit 414 Fundstellen zu ändern gewesen; so waren es acht. Die Zeit ging
stattdessen in die Absicherung — zwei Trigger, vier CHECKs und elf
Datenbanktests — und das ist die richtige Verteilung für eine Phase, deren
einziger Zweck es ist, dass eine Wehr die andere nicht sieht.
---
## Was hier nicht drin ist
+387 -1
View File
@@ -21,7 +21,393 @@ Die aufgewendete Zeit wandert nach jedem Abschnitt in die Spalte
| Phase 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
| Phase 8 — Lokale KI | 3:40 h | 1 | 04.09.2026 |
| Aufräumarbeiten (Issues #10–#12) | 1:20 h | 1 | 04.09.2026 |
| **Summe** | **24:20 h** | **11** | |
| Phase 9 — Kassenverwaltung | 4:10 h | 1 | 04.09.2026 |
| Phase 10 — Mandantenfähigkeit | 4:20 h | 1 | 05.09.2026 |
| Phase 11 — Freigabereife | 2:40 h | 1 | 05.09.2026 |
| 1.0.0 — Freigabe | 0:50 h | 1 | 06.09.2026 |
| **Summe** | **36:20 h** | **15** | |
---
## 2026-09-06 · 1.0.0 — Freigabe
**09:20 – 10:10 · 0:50 h · Version 1.0.0**
### Was entstanden ist
Kein Modul. Zwei Dinge, die zum Betrieb gehören, und eine Entscheidung.
| Was | Wofür |
|---|---|
| `update.sh` | Aktualisieren auf einem Linux-Server, in einem Befehl |
| `docs/KURZANLEITUNG.md` | Der Einstieg für die Wehr, nach Rollen sortiert |
| Version 1.0.0 | Ab hier semantische Versionierung |
### Entscheidungen
**Das Update-Skript sichert, bevor es irgendetwas anfasst.** Ein Update ist
der häufigste Anlass, eine Sicherung zu brauchen — und der Moment, in dem
niemand daran denkt. `--ohne-sicherung` gibt es, aber mit einer Warnung und
nicht als Vorgabe.
**Die Reihenfolge ist so gewählt, dass ein Abbruch harmlos bleibt.** Holen und
Bauen passieren, bevor umgeschaltet wird: schlägt der Bau fehl, läuft die alte
Fassung unverändert weiter. Erst danach wird umgeschaltet, und ab da nennt die
Fehlerbehandlung die Befehle für den Rückweg.
**Die Kurzanleitung ist aufgabenorientiert, nicht modulorientiert.** Die große
Anleitung hat 63 Kapitel und ist nach Funktionsbereichen sortiert — richtig
zum Nachschlagen, unbrauchbar zum Anfangen. Die Kurzanleitung fragt stattdessen
„was willst du tun": ein Gerät prüfen, einen Kontoauszug einlesen, einen
Termin absagen.
**Sie erklärt bei jedem heiklen Punkt das Warum.** Warum ein abgeschlossenes
Prüfprotokoll nicht änderbar ist, warum gegengebucht statt korrigiert wird,
warum der Atemschutzhinweis stehen bleibt. Wer den Grund kennt, sucht nicht
nach dem Weg drumherum.
**Die Roadmap hakt nicht alles ab.** Zwei Freigabevoraussetzungen sind
organisatorisch und stehen aus — die Abnahme des Löschkonzepts und die
Schulung. Sie stehen mit ihrem Stand dort, statt stillschweigend als erledigt
zu gelten. Eine Freigabe, die abhakt, was nicht geprüft wurde, ist keine.
### Wo es gehakt hat
**Die Ursache der Testflackerei gefunden — nach elf Phasen.**
Beim abschließenden Durchlauf fielen wieder einmal fünf, dann sechs, dann ein
Test aus, in wechselnder Besetzung. Dieses Mal habe ich nicht weitergemacht,
sondern fünf Läufe hintereinander mitgeschrieben: betroffen waren immer
Zusicherungen über Trigger — „Wiki-Fassungen bleiben", „Mandantentrennung",
„abgeschlossene Prüfprotokolle".
`ki-datenbank.test.ts` und `atemschutz-datenbank.test.ts` schalteten beim
Aufräumen ihre Trigger mit `ALTER TABLE ... DISABLE TRIGGER USER` ab. Das ist
eine Schemaänderung: sie gilt für die ganze Datenbank und für alle. Vitest
fährt die Testdateien parallel — während also die eine Datei abräumte, prüfte
die andere gerade, dass genau dieser Trigger greift, und sah keinen.
`SET LOCAL session_replication_role = 'replica'` bewirkt dasselbe, aber nur in
der eigenen Transaktion. Danach: fünf Läufe hintereinander 601 grün.
Rückwirkend erklärt das eine Reihe von Ausfällen, die ich als Flackern
abgetan habe — sie waren keines. Die Lehre ist unbequem: „läuft einzeln durch,
also Umgebung" ist eine bequeme Erklärung und oft die falsche.
| Weiteres | Ursache | Lösung |
|---|---|---|
| Ein Datenbanktest ließ sich nur einmal ausführen | `mandanten-datenbank.test.ts` räumte nur am Ende auf; ein abgebrochener Lauf legte den nächsten lahm | Räumt jetzt auch am Anfang auf — dasselbe Muster wie in Phase 10 bei `ki-datenbank` |
### Abweichung von der Vorgabe
**`update.sh` ist nicht auf einem Linux-Server erprobt.** Entwickelt wurde
unter Windows; geprüft sind die Syntax (`bash -n`), die Hilfe, die
Argumentbehandlung und der Wächter gegen unverbuchte Änderungen — alles unter
Git Bash. Der eigentliche Ablauf (fetch, build, up, Gesundheitsprüfung) läuft
erst dort, wo FLORIAN wirklich steht.
Das ist keine Nachlässigkeit, sondern die Grenze dieser Umgebung. Beim ersten
echten Update gehört `./update.sh --pruefen` vorangestellt: es holt nichts und
ändert nichts, zeigt aber, ob Docker, git und die `.env` gefunden werden.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 601 Tests grün
- `bash -n update.sh` — Syntax in Ordnung
- `./update.sh --help` und `--quatsch` — Ausgabe und Ablehnung wie vorgesehen
- Der Wächter gegen unverbuchte Änderungen greift (an einem schmutzigen
Arbeitsverzeichnis geprüft)
- Alle acht Bilder der Kurzanleitung existieren
- `npm run test` **fünfmal hintereinander** — jedes Mal 601 grün. Das war
nach elf Phasen der erste Lauf, bei dem ich das behaupten kann
- `npm run test:e2e` — **268 grün, kein Fehlschlag, kein Flackern.** Ebenfalls
der erste vollständig saubere Durchlauf in diesem Projekt
- `npm run build` — sauber
---
## 2026-09-05 · Phase 11 — Freigabereife
**14:30 – 17:10 · 2:40 h · Version 0.12.0**
### Was entstanden ist
Kein Modul, sondern die drei Software-Voraussetzungen für 1.0.0.
| Bereich | Umfang |
|---|---|
| Auskunft | `lib/dsgvo/auskunft.ts` und `bericht.ts`, `person.auskunft`, Dialog auf der Mitgliederseite |
| Sicherung | `scripts/sicherung.ts`, `wiederherstellen.ts`, `sicherung-zugang.ts`, `sicherung-sperren.ts` |
| Pipeline | `.gitea/workflows/pruefung.yml` mit zwei Aufträgen |
| Tests | 18 neue: 9 gegen die Auskunft, 11 gegen die Sicherung |
### Entscheidungen
**Die Auskunft trennt „über mich" von „von mir".** Was über die Person
gespeichert ist, steht vollständig drin — auch der gemahnte Beitrag und die
nicht bestandene G26. Was die Person *getan* hat, steht als Zahl drin. Die
zwölf Prüfprotokolle, die sie unterschrieben hat, gehören zum Nachweisbestand
der Wehr; vollständig ausgeliefert käme jeder über eine Selbstauskunft an die
Geräteakten, und die Auskunftspflicht wäre eine Hintertür.
**Über jedem Abschnitt steht der Zweck.** Art. 15 Abs. 1 verlangt nicht nur
die Daten, sondern auch Zwecke, Empfänger und den Hinweis auf die weiteren
Rechte. Eine Liste ohne das sieht aus wie eine Auskunft und ist keine — das ist
der Unterschied zwischen einem Datenbankexport und einem Schreiben an einen
Menschen.
**Eine Mutation, keine Abfrage.** Das Erstellen der Auskunft *ist* die
Offenlegung, und die gehört ins Protokoll. Als Abfrage würde sie
zwischengespeichert und mehrfach ausgeführt — sie wäre entweder nicht oder
zwanzigmal vermerkt.
**Sicherung und Wiederherstellung gehören zusammen.** Deshalb sind es zwei
Skripte in einem Zug und nicht eines. Der Probelauf ist die Vorgabe; ohne
`--wirklich` geschieht nichts.
**Eine Liste, zwei Verwendungen.** `sicherung-sperren.ts` treibt sowohl das
Wiederherstellen der Sperren als auch deren Prüfung. Zwei Listen wären zwei
Wahrheiten, und die falsche fiele erst im Notfall auf.
**Die Pipeline baut das eigene Postgres-Image.** Ein Dienst-Container mit
`postgres:17` ließe die Migrationen durchlaufen und scheiterte erst bei der
ersten Umkreissuche — die Pipeline prüfte dann etwas anderes als den Betrieb.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| **Eine Wiederherstellung entfernte alle Unveränderlichkeitsgarantien** | Zuerst `--no-acl` im Dump. Und selbst ohne das Flag greift beim Zurückspielen die Vorgabeprivilegierung aus dem Init-Skript: jede von `pg_restore` neu angelegte Tabelle bekommt alle Rechte, und `GRANT` kann nur hinzufügen | Das Skript setzt die Sperren nach dem Zurückspielen neu und prüft danach nach. Gefunden haben es vier Datenbanktests direkt nach der ersten Rückspielprobe |
| `pg_dump` lehnte die Verbindungszeichenkette ab | Prisma hängt `?schema=public` an — „invalid URI query parameter" | Die URL wird zerlegt, Benutzer und Datenbank gehen als Flags, das Passwort über `PGPASSWORD` |
| Feldnamen im Auskunftssammler stimmten reihenweise nicht | Aus dem Gedächtnis geschrieben statt aus dem Schema gelesen: `alarmierung` statt `alarmiertUm`, `beginn` statt `beginnUm`, `gemeldetVonId` statt `melderId` | Der Typechecker hat sie alle einzeln benannt — zehn Durchgänge, jeder eine Zeile |
| Ein eigener Test war falsch, nicht der Code | `new URL("localhost:5432")` gelingt in Node: „localhost:" gilt als Protokoll | Die Zusicherung prüft jetzt, was wirklich greift — die Protokollprüfung eine Zeile später |
| Ein Vorlagentest fiel im vollen Lauf aus | Er klickte auf „Speichern“, bevor React lief — dann verschickt das Formular sich selbst, und die Fehlerfläche entsteht nie. Unter Last dauert das Hydrieren länger. Dasselbe Muster wie beim Anmeldeskript in Phase 8 | Er wartet jetzt auf einen client-seitig erzeugten Knopf, bevor er klickt |
### Abweichung von der Vorgabe
**Die Pipeline ist nicht erprobt.** Sie ist geschrieben und syntaktisch
geprüft, aber in Gitea war zum Zeitpunkt dieser Phase kein `act_runner`
registriert. Ohne Runner erscheinen die Läufe als „wartend" und laufen nie.
Ob der Ablauf durchläuft, zeigt sich erst, wenn einer eingerichtet ist — das
steht so auch im README, damit niemand sich auf eine Prüfung verlässt, die
nicht stattfindet.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 599 Tests grün, davon 18 neu
- Auskunft von Hand erzeugt: 13 Abschnitte, 28 Angaben, 58 kB PDF
- **Sicherung und Wiederherstellung vollständig durchgeführt**: ein nach der
Sicherung angelegter Datensatz war danach weg, alle Zahlen stimmten wieder,
und die 599 Tests liefen nach dem Zurückspielen grün — einschließlich derer,
die die Sperren prüfen
---
## 2026-09-05 · Phase 10 — Mandantenfähigkeit
**08:10 – 12:30 · 4:20 h · Version 0.11.0**
### Was entstanden ist
Die einzige Phase bisher, die die Anmeldung selbst anfasst.
| Bereich | Umfang |
|---|---|
| Datenmodell | `Benutzer`, `Mitgliedschaft`, `MitgliedschaftArt`; `Person` verliert die Anmeldefelder |
| Absicherung | RLS für `mitgliedschaft`, zwei Trigger, vier CHECKs, ein REVOKE |
| Anmeldung | `lib/auth.ts` neu: Konto statt Akte, Mitgliedschaftswahl, Wehrwechsel im Token |
| Rechte | Rolle `AUFSICHT`, gefiltert statt aufgezählt; `istVerwalter` als Kontomerkmal |
| Oberfläche | Wehrwähler, Aufsichtshinweis, `/wehren` zur Verwaltung |
| Tests | 19 neue Unit-Tests (11 gegen die Datenbank), 13 E2E-Tests |
### Entscheidungen
**Die Sitzung behält ihre Form.** `sitzung.organisationId` und
`sitzung.personId` werden an 329 bzw. 85 Stellen in 61 Dateien gelesen. Hätte
sich ihre Bedeutung geändert, wäre das ein Umbau durch das halbe Projekt
geworden. Stattdessen entsteht die Sitzung nur *anders* — aus der aktiven
Mitgliedschaft statt aus der Person. Keine dieser 61 Dateien musste angefasst
werden.
**`personId` ist nullbar geworden.** Das war die eine Ausnahme davon, und zwar
absichtlich: eine Aufsichtssitzung hat keine Akte. Der Typechecker hat daraus
eine Liste gemacht — genau fünf Stellen verlangen eine Person, und alle fünf
sind Dinge, an denen die Kreisaufsicht nichts verloren hat (Anmeldemarke,
Dienstausweis, Prüfungswizard, Dateiupload, Atemschutz). Ein `?? ""` hätte
dieselben fünf Stellen still passieren lassen.
**`Benutzer` ohne `organisationId`.** Die zweite Ausnahme von der
Architekturregel nach `Berechtigung` — und dieselbe Begründung: die Tabelle
steht über allen Mandanten, weil sie der Angelpunkt ist, an dem ein Mensch zu
mehreren Wehren gehört. Geschützt über Rechte statt RLS.
**`AUFSICHT` steht nicht im Prisma-Enum.** Alle anderen Rollen werden einem
Menschen in einer Wehr zugewiesen. Diese ergibt sich aus der Mitgliedschaftsart.
Sie vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an
einer anderen verteilen könnte.
**Ihre Rechte werden gefiltert, nicht aufgezählt.** Alles, was `.lesen` heißt,
minus vier ausdrückliche Ausnahmen. Eine Liste von Hand wäre bei Phase 11
veraltet, und zwar unbemerkt — ein neues Modul brächte Rechte mit, die niemand
nachträgt. So bekommt die Aufsicht neue Leserechte automatisch und neue
Schreibrechte nie. Der Test dazu ist entsprechend formuliert: nicht „diese
dreizehn Rechte", sondern „kein einziges, das nicht lesen heißt".
**Die Kasse ist ausgenommen**, obwohl `kasse.lesen` auf `.lesen` endet. Sie
gehört dem Verein oder der Kommune, nicht der Kreisaufsicht. Sie wäre nur
deshalb dabei gewesen, weil das Recht zufällig richtig heißt — und genau
dagegen ist die Ausnahmeliste da.
**Die Migration ist datenerhaltend, mit Abbruchprüfung.** Konten und
Zuordnungen entstehen, *bevor* die alten Spalten fallen; am Ende prüft ein
`DO`-Block, dass keine Person mit Anmeldedaten ohne Konto dasteht, und bricht
sonst ab. Eine Migration, die die Anmeldung anfasst und auf halbem Weg
liegenbleibt, sperrt alle aus.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Nach dem Wehrwechsel landete alles auf der Anmeldeseite | `aktuelleSitzung()` verlangte eine Personenkennung — die Aufsicht hat keine und galt damit als nicht angemeldet | Eine Sitzung ist gültig, wenn Wehr und Konto feststehen; die Akte darf fehlen |
| Der Wechsel schien nicht zu greifen | Er griff: das Token trug die neue Wehr. Es war der dritte hängengebliebene Entwicklungsserver dieser Woche — `npm run build` neben laufendem `next dev` macht `.next` unbrauchbar, und der Server antwortet danach nur noch mit 500 | Server beenden, `.next` löschen. Diesmal notiert, damit es nicht ein viertes Mal passiert |
| Der Seed brach ab | Er legt die Rollen aus `ROLLEN` an, und dort steht jetzt `AUFSICHT` — die es im Prisma-Enum bewusst nicht gibt | Im Seed gefiltert, mit Begründung |
| `unstable_update` nahm den Wechselwunsch nicht an | Es akzeptiert nur `Partial<Session>` | Der Wunsch reist als `user.mitgliedschaftId` und wird serverseitig gegen die Mitgliedschaften geprüft |
| Die Nachbarwehr war fest verdrahtet | `ff-nachbarshausen` für jeden Mandanten — genau die Vermischung von Test- und Beispieldaten, die Phase 8 beseitigt hat | Aus dem Mandanten abgeleitet: `<slug>-nachbar` |
| Ein alter Datenbanktest wurde unzuverlässig | `ki-datenbank.test.ts` legte Wehr und Person idempotent an, die Wiki-Seite aber nicht. Die neue Testdatei verschob die Reihenfolge, und ein Rest aus einem abgebrochenen Lauf ließ den ganzen Datei-Lauf scheitern | Auch dort ein `upsert` |
| Nach `db:test-reset` zeigte der Wehrwähler nur noch die betreute Wehr | Der Reset löschte die Testwehr, aber nicht ihre Nachbarwehr. Der Seed sah die Nachbarwehr, hielt alles für erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an | Der Reset nimmt Nachbarwehr und Kreiskonto mit; der Seed prüft beide Teile getrennt statt mit einem gemeinsamen Ausstieg. Gefunden hat es ein E2E-Test |
| Im vollen Lauf fielen fremde Testdateien mit „Anmeldung fehlgeschlagen“ aus | Der Fehlversuch-Test meldete sich mit der Adresse des Administrators an. Fünf Fehlversuche sperren das Konto fünf Minuten — bei zwei Projekten und Wiederholungen kam das zusammen, und alle anderen Dateien brauchen dasselbe Konto | Der Test nimmt jetzt eine Adresse, die es nicht gibt. Die Meldung muss ohnehin dieselbe sein, damit sie nicht verrät, ob es die Adresse gibt |
### Abweichung von der Vorgabe
**Zwei Architekturregeln bekommen eine dokumentierte Ausnahme.**
„Jede Tabelle hat `organisationId`" gilt für `Benutzer` nicht — sie kann es
nicht, ohne den Zweck der Tabelle aufzuheben. Die Ausnahme steht im Schema, in
der Migration und in der Roadmap, und die Tabelle ist dafür über Rechte
geschützt statt über RLS.
„Berechtigungen zentral in `lib/authz.ts`" gilt für den Verwalterzugang nicht:
er hängt an `Benutzer.istVerwalter` und nicht an einem Recht. Rechte gelten je
Wehr — ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die
Hintertür, die diese Phase gerade schließen soll.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 581 Tests grün, davon 11 neu gegen eine echte Datenbank
- `npm run test:e2e` — grün über Desktop und Tablet, davon 26 neu
- `npm run db:seed` und `npm run db:test-reset` — beide Mandanten samt
Nachbarwehr und Kreisebene
- Anmeldung nach der Umstellung von Hand geprüft: Konto gefunden, Passwort
richtig, Sitzung trägt Konto, Mitgliedschaft und Verwaltermerkmal
---
## 2026-09-04 · Phase 9 — Kassenverwaltung
**12:40 – 16:50 · 4:10 h · Version 0.10.0**
### Was entstanden ist
Neun Modelle, drei Trigger, zwölf CHECKs, sechs Router, fünf Ansichten und
ein Kassenjahr im Seed.
Der Reihe nach, wie es CLAUDE.md vorgibt: Schema, Migration, Serverlogik, UI,
Tests.
| Bereich | Umfang |
|---|---|
| Datenmodell | `Kassenkonto`, `Buchungskategorie`, `Buchung`, `Beitragssatz`, `Beitrag`, `Rechnung`, `Rechnungsposition`, `Kassenabschluss`, `Abschlussposten` |
| Absicherung | RLS für alle neun Tabellen, drei Trigger, zwölf CHECKs, ein REVOKE |
| Bibliothek | `lib/kasse/betrag.ts`, `bankimport.ts`, `vorschlag.ts`, `texte.ts`, `dokumente.ts` |
| Router | Konten, Kategorien, Buchungen, Beiträge, Rechnungen, Abschluss |
| Oberfläche | Übersicht, Kassenbuch, Beiträge, Rechnungen, Abschluss mit Auswertung |
| Hintergrund | Rechnungs-PDF, Kassenprüfbericht, Meldung überfälliger Posten |
| Tests | 67 Unit-Tests (13 gegen eine echte Datenbank), 13 E2E-Tests |
### Entscheidungen
**Die Sperren sitzen in der Datenbank, nicht im Router.** Die ganze Phase
hängt an einem Satz: was geprüft ist, ändert sich nicht mehr. Ein
Zustandscheck in `buchung.aendern` erfüllt das nicht — er gilt für genau
diesen einen Weg. Ein zweiter Router, ein Skript, ein Import, und der geprüfte
Bestand ist ein anderer, ohne dass irgendwo etwas auffällt. Also drei Trigger:
festgeschriebene Buchung unveränderbar, geprüfter Zeitraum zu, festgeschriebener
Abschluss fest.
**Jeder Trigger hat genau eine Ausnahme, und jede hat einen Grund.** Die
Buchung lässt den Verweis auf ihre Gegenbuchung eintragen — eine Stornierung
entsteht später, sie einzutragen ist keine Änderung des Vorgangs, sondern
seine Fortschreibung. Der Abschluss lässt den Prüfbericht nachtragen, denn das
PDF entsteht im Hintergrund und damit nach dem Festschreiben. Ohne diese
Ausnahme gäbe es nie einen Bericht.
**Der Bestand wird gerechnet, nie gespeichert.** Ein gespeicherter Bestand
neben einer nachgetragenen Buchung wäre falsch, ohne falsch auszusehen — und
genau danach sucht ein Kassenprüfer. Bei den Größenordnungen einer
Feuerwehrkasse kostet das Rechnen nichts.
**Der Kategorievorschlag geht dem eigenen Bestand vor der Stichwortliste
nach.** Wenn eine Wehr Diesel seit Jahren unter „Fahrzeuge" bucht statt unter
„Kraftstoff", ist das ihre Entscheidung, und sie soll dabei bleiben dürfen.
Die Stichwortliste greift erst, wenn es noch keinen Bestand gibt. Unter der
Hälfte gemeinsamer Wörter schweigt der Vorschlag: ein falscher wird
übernommen, ohne gelesen zu werden.
**Der Bankimport erzeugt Entwürfe, keine Buchungen.** Und jeder Umsatz trägt
eine Kennung, damit ein zweimal eingelesener Auszug als solcher erkannt wird.
Ohne das wäre es ein doppelt gebuchter Monat, und der fiele erst beim
Kassenabschluss auf — also drei Monate später.
**Kein XML-Parser für CAMT.** Gebraucht werden sechs Felder aus einer flachen
Struktur, die Datei kommt von der eigenen Bank. Der Parser ist kürzer als die
Einbindung, und in einer Anwendung ohne Netzzugang ist jede Abhängigkeit
weniger eine Sorge weniger. Namensräume werden ignoriert — genau das, was ein
Parser hier auch täte.
**In der Prüfmaske stehen die gezählten Felder leer.** Der Buchbestand ist zu
sehen, weil der Prüfer ihn für die Differenz braucht. Vorbelegt wird nichts:
wer erst das Buch sieht und dann zählt, zählt, bis es passt.
### Wo es gehakt hat
| Problem | Ursache | Lösung |
|---|---|---|
| Der Abschluss ließ sich nicht mit seinem Bericht versehen | Der Trigger sperrte auch `berichtDateiId` — das PDF entsteht aber erst nach dem Festschreiben | Ausnahme für genau dieses Feld, wie beim Stornierungsverweis der Buchung |
| Die Rechnungsnummer war ein Wettlauf | Höchste lesen, dann schreiben: zwei gleichzeitige Rechnungen lesen dieselbe Nummer | Zusammenstoß erkennen und die nächste nehmen; gefunden hat es der parallele E2E-Lauf über zwei Projekte |
| Eine Organisation ließ sich nicht mehr löschen | Der DELETE-Trigger der Buchung kannte den Aufbewahrungsschalter nicht, den Audit-Log und Prüfprotokoll haben | `app.audit_bereinigung` auch dort — ohne ihn wäre die DSGVO-Löschung nicht durchführbar |
| Der Datenbanktest ließ sich nur einmal ausführen | Dieselbe Ursache: das Aufräumen scheiterte an den festgeschriebenen Buchungen, und der Rest lief im nächsten Lauf gegen einen geprüften Zeitraum | siehe oben |
| E2E: „Barkasse" fand sieben Elemente | Das Wort steht auch in Verwendungszwecken | über die Kartenüberschrift angesprochen |
| E2E: die erste Maifest-Zeile war die falsche | Absteigend sortiert steht das laufende Jahr oben, und das ist nicht festgeschrieben | eine Buchung genommen, die es nur im Vorjahr gibt — und die Gegenprobe dazu |
| Prisma wollte zum zehnten Mal die Trigramm-Indizes aus Phase 1 löschen | Sie stehen in einer Roh-SQL-Migration, nicht im Schema | 30 DropIndex-Blöcke entfernt, Kommentar in die Migration |
| Der volle E2E-Lauf meldete quer durch alle Module Fehler | `npm run build` während eines laufenden `next dev`: der Produktionslauf überschreibt `.next`, und der Entwicklungsserver antwortet danach nur noch mit 500 | Entwicklungsserver beenden, `.next` löschen, neu laufen lassen — 239 grün |
| Ein E2E-Test aus Phase 0 wurde rot | Er sicherte zu, dass die Kasse **kein** Verweis ist — die Zusicherung ist mit dieser Phase abgelaufen | umgeschrieben: die Kasse ist jetzt ein Verweis. Ein künftiges Modul zum Ausgrauen gibt es nicht mehr |
### Abweichung von der Vorgabe
Keine. `Phase-9.md` ist vollständig umgesetzt, einschließlich CAMT.053, MT940,
Nummernkreis, Kassenprüfbericht und Export.
Der Beleg-Anhang an einer Buchung nutzt den `Belegentwurf` aus Phase 8 mit —
er hat dafür einen `buchungId` bekommen. Fotografieren und Erfassen war
bereits da; neu ist nur, dass daraus eine Buchung wird.
### Nachweise
- `npm run typecheck` — ohne Ausgabe
- `npm run lint` — ohne Befund
- `npm run test` — 562 Tests grün, davon 13 neu gegen eine echte Datenbank
- `npm run test:e2e` — 239 Tests grün über Desktop und Tablet, davon 26 neue
- `npm run build` — fünf neue Routen unter `/kasse`
- `npm run db:seed` — Kassenjahr angelegt, Abschluss festgeschrieben
- Rechnungs-PDF (41 kB) und Kassenprüfbericht (45 kB) einmal von Hand über
Gotenberg erzeugt und in MinIO abgelegt — die beiden Vorlagen haben sonst
keinen automatischen Nachweis
---
+283
View File
@@ -0,0 +1,283 @@
# FLORIAN — Kurzanleitung
Alles, was man am Anfang braucht, auf ein paar Seiten. Nach Rollen sortiert:
such dir den Abschnitt, der zu dir gehört, und lies den Rest später.
Ausführlich steht alles in der [Anleitung](ANLEITUNG.md) — 63 Kapitel, zum
Nachschlagen, nicht zum Durchlesen.
---
## Für alle
### Anmelden
FLORIAN läuft im Browser. Die Adresse bekommst du vom Kommandanten.
![Anmeldung](screenshots/01-anmeldung-light.png)
Nach der Anmeldung landest du auf der Übersicht: was ansteht, was fällig ist,
was gemeldet wurde.
**Das Passwort gehört nach der ersten Anmeldung geändert.** Oben rechts auf
deinen Namen.
### Was du überall findest
| Links in der Leiste | Wofür |
|---|---|
| **Übersicht** | Die Startseite: Fristen, Mängel, nächste Termine |
| **Termine** | Übungsabende, Lehrgänge, Versammlungen — mit Rückmeldung |
| **Dienstausweis** | Dein Ausweis mit QR-Code, auch fürs Handy |
| **Wissen** | Merkblätter und Dienstanweisungen der Wehr |
Du siehst nur, was zu deiner Rolle gehört. Fehlt dir etwas, sag dem
Kommandanten Bescheid — Rollen werden dort vergeben.
### Zu einem Termin zurückmelden
**Termine** öffnen, den Termin anklicken, **Zusagen** oder **Absagen**. Fertig.
Wer absagt, kann einen Grund angeben; das hilft dem Ausbilder bei der Planung.
### Am Terminal im Gerätehaus
Am Wandtablet meldest du dich mit **PIN oder Ausweiskarte** an — nicht mit
E-Mail und Passwort. Deine PIN bekommst du vom Kommandanten.
![Terminal](screenshots/25-kiosk-kacheln-light.png)
Dort geht: anmelden zum Dienst, einen Mangel melden, eine Fahrt eintragen,
Schutzkleidung zur Reinigung geben.
---
## Für den Gerätewart
Das ist der Bereich, in dem FLORIAN am meisten abnimmt.
### Der wichtigste Bildschirm: Prüfungen
![Fristen](screenshots/11-fristen-light.png)
Unter **Prüfungen** steht, was wann fällig wird. Rot heißt überfällig, gelb
heißt demnächst. Die Fristen rechnet FLORIAN selbst aus den Prüfintervallen
der Gerätetypen aus — du musst nichts nachhalten.
### Ein Gerät prüfen
1. **Prüfungen** → das fällige Gerät anklicken → **Prüfung durchführen**.
2. Die Checkliste durchgehen. Sie kommt aus der Wartungsvorlage des Gerätetyps.
3. Ergebnis eintragen, unterschreiben, abschließen.
![Prüfprotokoll](screenshots/18-pruefprotokoll-light.png)
Danach entsteht ein **Prüfprotokoll als PDF** — das ist der Nachweis gegenüber
der Unfallkasse.
> **Achtung:** Ein abgeschlossenes Protokoll lässt sich nicht mehr ändern. Das
> ist Absicht — ein nachträglich änderbarer Nachweis wäre keiner. Ist etwas
> falsch, wird eine neue Prüfung erfasst.
### Ein Gerät anlegen
**Geräte** → **Gerät anlegen**. Pflicht sind nur Inventarnummer und
Bezeichnung; der Rest kann nachkommen.
Wähle möglichst einen **Gerätetyp** — daran hängen Prüfintervall und
Lebensdauer, und ohne Typ rechnet FLORIAN keine Frist.
### Einen Mangel bearbeiten
Mängel kommen meist vom Terminal — jemand meldet sie beim Einrücken.
**Mängel** → anklicken → jemandem zuweisen → nach der Behebung **erledigen**.
Ein Gerät mit einem schweren Mangel wird automatisch gesperrt.
### Was noch dazugehört
| Wo | Wofür |
|---|---|
| **Rückrufe** | Herstellerrückrufe: welche eurer Geräte betroffen sind |
| **Lebensdauer** | Helme, Schläuche, Schutzkleidung mit Höchstalter |
| **Fahrzeuge** | Beladung nach Geräteräumen |
---
## Für den Atemschutzgerätewart
> **Der wichtigste Satz zuerst:** Die Überwachungstafel am Fahrzeug bleibt
> führend. FLORIAN ergänzt sie, ersetzt sie nicht. Das steht auch dauerhaft
> über jeder Ansicht, und es soll dort stehen.
### Wer darf unter Pressluftatmer?
![Atemschutz](screenshots/31-atemschutz-light.png)
**Atemschutz** zeigt jeden Träger mit seinem Stand: G26, Belastungsübung,
Einsatzübung. Wer eine der Voraussetzungen nicht erfüllt, steht rot da — mit
Klartext, woran es liegt, nicht nur mit einer Farbe.
### Eine G26 eintragen
**Atemschutz** → **Nachweise** → Person wählen → G26 erfassen. Untersuchungs­datum,
Ergebnis und Gültigkeit reichen; den Befund speichert FLORIAN bewusst nicht.
Dasselbe für Belastungsübungen.
### Eine Überwachung führen
**Atemschutz** → **Überwachung eröffnen**. Trupp zusammenstellen, Druckwerte
in Intervallen eintragen, Rückzugsdruck im Blick behalten. FLORIAN rechnet den
Rückzugsdruck und warnt, bevor es knapp wird.
![Überwachung](screenshots/33-atemschutz-ueberwachung-light.png)
Nach dem Ende entsteht ein vollständiges Protokoll als PDF.
> Solange ein Trupp im Einsatz ist, lässt sich die Überwachung nicht beenden.
---
## Für den Kommandanten
### Mitglieder und Rollen
**Mitglieder** → Person anklicken → Rollen vergeben.
Neun Rollen, jede mit festem Rechteumfang. Die wichtigsten: Gerätewart,
Atemschutzgerätewart, Kassier, Schriftführer, Ausbilder.
**Jemand kommt nicht hinein?** Dann hat die Person eine Akte, aber kein Konto.
Konten verwaltet, wer den Server betreibt — unter *Wehren und Konten*.
### Einen Einsatz erfassen
**Einsätze** → **Einsatz anlegen**. Die sechs Zeitstempel (Alarmierung,
Ausrücken, Eintreffen, Einsatzende, Einrücken, Einsatzbereitschaft), die
beteiligten Fahrzeuge und die Mannschaft.
![Einsatz](screenshots/22-einsatz-light.png)
Daraus entsteht der **Einsatzbericht als PDF** und die Statistik fürs Jahr.
### Kostenersatz abrechnen
Fehlalarm einer Brandmeldeanlage, Öleinsatz mit Verursacher, vermieteter
Schulungsraum: **Kasse** → **Rechnungen** → *Rechnung schreiben*. Die Nummer
vergibt FLORIAN fortlaufend.
### Wenn jemand Auskunft über seine Daten verlangt
Kommt selten vor, hat aber eine Frist von einem Monat.
**Mitglieder** → Person → **Datenauskunft** → *Auskunft erstellen*. Daraus
entsteht ein PDF mit allem, was über die Person gespeichert ist. Ausdrucken
und aushändigen, fertig.
---
## Für den Kassier
![Kasse](screenshots/45-kasse-light.png)
### Buchen
**Kasse** → **Buchungen** → *Buchung erfassen*. Der Betrag ist immer positiv;
ob Einnahme oder Ausgabe, sagen die beiden Knöpfe daneben.
FLORIAN schlägt eine Kategorie vor — meist die richtige, weil er nachsieht,
wie ihr dieselbe Sache zuletzt gebucht habt.
### Kontoauszug einlesen
Statt fünfzig Umsätze abzutippen: im Online-Banking den **Umsatzexport**
herunterladen (CAMT.053 oder MT940), dann **Kasse** → **Buchungen** →
*Kontoauszug*.
Es entstehen **Entwürfe**, keine fertigen Buchungen — durchsehen musst du sie.
Umsätze, die schon in der Kasse stehen, erkennt FLORIAN und hakt sie nicht
noch einmal ab.
### Beiträge
Einmal im Jahr: **Kasse** → **Beiträge** → *Beitragslauf*. Vorher steht da, wie
viele Beiträge entstehen — und wer keinen passenden Satz hat.
Zahlungen hakst du in der Liste ab; dabei entsteht gleich die Buchung.
### Festschreiben — und was das bedeutet
Eine neue Buchung ist erst einmal ein **Entwurf** und änderbar. Mit
*Festschreiben* wird sie unveränderbar.
Ist danach etwas falsch, wird **gegengebucht**: eine zweite Buchung in die
andere Richtung, mit Begründung. Beide bleiben stehen. Das sieht nach mehr
Aufwand aus und ist genau der Punkt — eine verschwundene Buchung kann ein
Kassenprüfer nicht prüfen, eine berichtigte schon.
### Kassenprüfung
**Kasse** → **Abschluss** → *Kassenprüfung anlegen*. Links steht der Bestand
laut Buch, rechts trägst du ein, was gezählt wurde.
Bleibt eine Differenz, muss sie erklärt werden — sonst lässt sich der Abschluss
nicht festschreiben. In fünf Jahren weiß sonst niemand mehr, woher sie kam.
Danach entsteht der **Kassenprüfbericht als PDF** mit Unterschriftsfeldern.
---
## Für den, der den Server betreibt
### Sichern
```bash
npm run sicherung
```
Sichert Datenbank **und** Dateien. Das Ergebnis gehört auf ein anderes Gerät —
eine Sicherung auf derselben Festplatte hilft gegen einen Festplattenfehler
nicht.
### Einmal im Jahr proben
```bash
npm run wiederherstellen -- --quelle sicherungen/<Ordner>
```
So passiert nichts, es wird nur angezeigt, was geschähe. Erst mit `--wirklich`
wird zurückgespielt.
> Eine Sicherung, die nie zurückgespielt wurde, ist eine Vermutung. Der Ordner
> kann leer sein, der Auszug abgeschnitten — man erfährt es sonst an dem Tag,
> an dem man es nicht erfahren will.
### Aktualisieren
```bash
./update.sh --pruefen # nachsehen, ob es etwas Neues gibt
./update.sh # einspielen
```
Das Skript sichert von sich aus, bevor es etwas anfasst, und sagt bei einem
Fehler, wie der vorherige Stand zurückkommt.
---
## Wenn etwas nicht geht
| Was | Woran es meist liegt |
|---|---|
| **Anmeldung geht nicht** | Nach fünf Fehlversuchen ist das Konto fünf Minuten gesperrt. Kurz warten. |
| **Ein Menüpunkt fehlt** | Die Rolle fehlt. Der Kommandant vergibt sie unter *Mitglieder*. |
| **Ein PDF entsteht nicht** | Es wird im Hintergrund erzeugt; nach ein paar Sekunden die Seite neu laden. |
| **Das Terminal meldet niemanden an** | PIN falsch, oder das Tablet ist nicht mehr eingerichtet. |
| **Eine Buchung lässt sich nicht ändern** | Sie ist festgeschrieben. Gegenbuchen. |
Steht die Antwort nicht dabei: [Anleitung, letztes Kapitel](ANLEITUNG.md).
---
*FLORIAN läuft auf einem Server, den die Feuerwehr selbst betreibt. Es gibt
keine Telemetrie, keine Cloud und keine Weitergabe an Dritte — die Daten
bleiben im Gerätehaus.*
+94 -19
View File
@@ -18,8 +18,10 @@ Phase 5 ████████████████████ fertig
Phase 6 ████████████████████ fertig 0.7.0
Phase 7 ████████████████████ fertig 0.8.0
Phase 8 ████████████████████ fertig 0.9.0
Phase 9 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.10.0
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.0
Phase 9 ████████████████████ fertig 0.10.0
Phase 10 ████████████████████ fertig 0.11.0
Phase 11 ████████████████████ fertig 0.12.0
Freigabe ████████████████████ fertig 1.0.0
```
---
@@ -234,7 +236,7 @@ Dafür bleibt **alles andere ohne Modell vollständig benutzbar**: Wiki lesen un
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
ein Satz, der sagt was fehlt und wie es ohne geht.
## Phase 9 — Kassenverwaltung · als Nächstes
## Phase 9 — Kassenverwaltung · fertig
Am 03.09.2026 ergänzt. Die Rolle `KASSIER` war seit Phase 0 angelegt, ein Modul
dazu fehlte in der ursprünglichen Planung. Spezifikation: `Phase-9.md`.
@@ -253,20 +255,92 @@ Kassenabschluss friert seinen Zeitraum ein, danach nimmt die Buchungsmaske
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
Fließkommazahl.
**Danach kann die Wehr:** ihre Kasse führen und den Kassenprüfbericht auf
Beides sitzt in Triggern und nicht im Router: ein vergessener Zustandscheck,
ein Skript oder ein Import würden sonst reichen, um den geprüften Bestand zu
ändern, ohne dass irgendwo etwas auffällt.
**Die Wehr kann jetzt:** ihre Kasse führen und den Kassenprüfbericht auf
Knopfdruck erzeugen.
## 1.0.0 — Freigabe
## Phase 10 — Mandantenfähigkeit · fertig
Am 05.09.2026 ergänzt. Datenmodell und RLS trugen seit Phase 0 mehrere Wehren;
was fehlte, war alles darüber.
- `Benutzer` und `Mitgliedschaft`: das Anmeldekonto ist von der Personenakte
gelöst. Ein Mensch, ein Passwort, beliebig viele Wehren.
- Wehrwähler in der Seitenleiste — er erscheint nur, wenn es etwas zu wählen
gibt.
- Die Rolle `AUFSICHT` für die Kreisebene: Kreisbrandrat und
Kreisbrandinspektion sehen die betreuten Wehren und ändern nichts.
- Verwaltung der Installation unter `/wehren`: Wehren anlegen, Konten führen,
Zuordnungen vergeben.
Die Entscheidung, die alles andere bestimmt: **das Konto trägt keine
Mandantenkennung.** Es kann keine tragen — es ist ja der Angelpunkt, an dem
ein Mensch zu mehreren Wehren gehört. Geschützt wird es deshalb wie
`Berechtigung` über Rechte statt über Row Level Security, und was ein Konto in
einer Wehr darf, entscheidet nicht es selbst, sondern seine `Mitgliedschaft` —
und die trägt `organisationId` wie jede andere Tabelle.
**Bewusst nicht dabei:** wehrübergreifende Auswertungen. Eine Zahl über
mehrere Wehren hinweg braucht eine abgestimmte Definition, und ohne die ist
sie schlimmer als keine. Die Aufsicht sieht jede Wehr einzeln.
**Die Wehr kann jetzt:** sich einen Server mit anderen teilen, ohne dass
jemand über die Grenze sieht — und die Kreisebene kann hineinschauen, ohne
etwas anfassen zu können.
## Phase 11 — Freigabereife · fertig
Am 05.09.2026 umgesetzt. Kein Modul, sondern die drei Software-Voraussetzungen
für die Freigabe.
- **Auskunft nach Art. 15 DSGVO.** Das Recht `person.export` stand seit Phase 0
im Katalog und war zugewiesen — umgesetzt war nichts. Ohne Auskunftsseite ist
ein Löschkonzept nicht abnehmbar.
- **Sicherung und Wiederherstellung** mit einer durchgeführten Rückspielprobe.
- **Pipeline** in Gitea Actions: typecheck, lint, Migrationen, Seed und Tests.
Die Rückspielprobe hat sich sofort bezahlt gemacht: der erste Versuch lief
sauber durch und hatte dabei sämtliche Unveränderlichkeitsgarantien der
Datenbank entfernt. Gefunden haben es die Datenbanktests unmittelbar danach —
also genau die Probe, wegen der 1.0.0 sie verlangt.
**Die Wehr kann jetzt:** eine Selbstauskunft erteilen, ihren Bestand sichern
und ihn nachweislich zurückholen.
## 1.0.0 — Freigabe · am 06.09.2026 erteilt
Kein neues Modul, sondern eine Entscheidung: die Wehr setzt FLORIAN im
Echtbetrieb ein. Voraussetzungen:
Echtbetrieb ein.
- Datenübernahme aus der bisherigen Verwaltung abgeschlossen und abgeglichen
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
- Löschkonzept nach DSGVO abgenommen
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
dokumentiert abgeschaltet — oder bewusst stehen gelassen
**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch
der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft
wurde, ist keine.
| Voraussetzung | Stand |
|---|---|
| Datenübernahme aus der bisherigen Verwaltung | **entfällt.** Die Wehr pflegt den Bestand von Hand ein; es gibt kein Altsystem, aus dem übernommen würde. Für Mitglieder, Fahrzeuge und Geräte steht der Import-Assistent bereit, falls sich das ändert. |
| Sicherungskonzept steht und ist einmal wiederhergestellt worden | **erledigt** mit Phase 11. Die Probe hat dabei einen Fehler gefunden, der sonst erst im Notfall aufgefallen wäre. |
| Löschkonzept nach DSGVO | **technisch vollständig** seit Phase 11: Auskunft nach Art. 15, Anonymisierung im Aufbewahrungslauf, konfigurierbare Fristen. Die Abnahme durch die Wehr ist ein organisatorischer Schritt und steht aus. |
| Schulung von Kommandant, Gerätewart und Atemschutzgerätewart | **steht aus.** Sie läuft nach der Freigabe; Grundlage ist die [Kurzanleitung](KURZANLEITUNG.md). |
| Hinweis zur analogen Atemschutzüberwachung | **bewusst stehen gelassen.** `ATEMSCHUTZ_HINWEIS=true`. FLORIAN bleibt damit ergänzend; die Tafel am Fahrzeug ist führend. Das Abschalten verlangt ohnehin einen Kommandantenbeschluss samt Fundstelle. |
Die beiden offenen Punkte sind **organisatorisch, nicht technisch**: an der
Software hängt nichts davon ab. Sie stehen hier, damit später nachlesbar ist,
was zum Zeitpunkt der Freigabe erledigt war und was nicht.
### Was 1.0.0 bedeutet — und was nicht
**Bedeutet:** Der Funktionsumfang ist vollständig, die Datenbank ist stabil,
Migrationen sind aufwärtskompatibel, und ab hier gilt semantische
Versionierung. Ein Update bricht nichts, ohne dass es in der Hauptnummer
steht.
**Bedeutet nicht:** dass keine Fehler mehr da sind. Es bedeutet, dass die
Wehr echte Daten hineingeben kann, ohne befürchten zu müssen, sie beim
nächsten Update zu verlieren.
---
@@ -276,8 +350,11 @@ Am 03.09.2026 entschieden:
| Punkt | Entscheidung |
|---|---|
| Kassenmodul | Wird als **Phase 9** ergänzt. Spezifikation in `Phase-9.md`, Aufwand 8–11 PT. |
| Kassenmodul | Als **Phase 9** ergänzt und mit 0.10.0 umgesetzt. Spezifikation in `Phase-9.md`. |
| `Berechtigung` ohne `organisationId` | Bleibt so. Die Tabelle ist der systemweite Katalog dessen, was die Software kann, für alle Wehren identisch — mandantenbezogen wird es erst über `RolleBerechtigung`. Für die Anwendungsrolle nur lesbar. |
| `Benutzer` ohne `organisationId` | Notwendige Ausnahme, seit Phase 10. Die Tabelle ist der Angelpunkt der Doppelmitgliedschaft — mit Mandantenkennung könnte sie das nicht sein. Geschützt über Rechte statt RLS, wie `Berechtigung`: die Anwendungsrolle darf lesen und die Anmeldefelder fortschreiben, mehr nicht. |
| `AUFSICHT` nicht im Prisma-Enum | Die Kreisebene ergibt sich aus der Mitgliedschaftsart, nicht aus einer Rollenzuweisung. Vergebbar zu machen hiesse, dass jemand innerhalb einer Wehr Rechte an einer anderen verteilen könnte. |
| `istVerwalter` als Kontomerkmal | Kein Recht. Rechte gelten je Wehr; wer Wehren anlegt, steht über allen. Ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre die Hintertür, die Phase 10 vermeiden soll. |
| Löschausnahme im Audit-Log | Bleibt so. `UPDATE` ist dauerhaft gesperrt, `DELETE` nur mit `SET LOCAL app.audit_bereinigung = 'an'` im Aufbewahrungslauf des Workers. Ohne diese Ausnahme wäre das Löschkonzept nach DSGVO nicht umsetzbar. |
## Weitere Kandidaten für später
@@ -288,10 +365,8 @@ Am 03.09.2026 entschieden:
| Kleiderkammer und Wäscheverwaltung | in Phase 4 nur als Wizard, ohne Bestandsführung | 4–6 PT |
| Jugendfeuerwehr mit eigener Abzeichenverwaltung | eigene Fachlichkeit | 5–7 PT |
| Schnittstelle zur Integrierten Leitstelle | Alarmdaten automatisch übernehmen | 6–10 PT, stark abhängig von der ILS |
| Mehrere Wehren je Installation | technisch bereits möglich, Oberfläche fehlt | 4–6 PT |
Die Mandantenfähigkeit ist der Punkt, der ohne Zusatzaufwand am weitesten
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
der nächste sinnvolle Schritt.
Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
Definition dessen, was verglichen werden soll.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 224 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 413 KiB

After

Width:  |  Height:  |  Size: 413 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 179 KiB

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 745 KiB

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 764 KiB

After

Width:  |  Height:  |  Size: 824 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 275 KiB

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 KiB

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 726 KiB

After

Width:  |  Height:  |  Size: 789 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 742 KiB

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 184 KiB

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 291 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

After

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 628 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 638 KiB

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 199 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 937 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "florian",
"version": "0.9.0",
"version": "1.0.0",
"private": true,
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
"engines": {
@@ -23,6 +23,8 @@
"db:migrate:dev": "prisma migrate dev",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"sicherung": "tsx scripts/sicherung.ts",
"wiederherstellen": "tsx scripts/wiederherstellen.ts",
"db:test-reset": "tsx scripts/testdaten.ts",
"db:studio": "prisma studio",
"screenshots": "tsx scripts/screenshots.ts",
@@ -0,0 +1,346 @@
-- =============================================================================
-- Phase 9 — Kassenverwaltung
--
-- HINWEIS zu den entfernten DropIndex-Bloecken: Prisma schlaegt bei jeder
-- Migration erneut vor, die von Hand angelegten GiST- und Trigram-Indizes aus
-- Phase 1 abzuraeumen. Es kennt sie nicht, weil sie nicht aus dem Schema
-- stammen. Neunte Migration in Folge, in der sie wieder herausgenommen werden.
-- =============================================================================
-- CreateEnum
CREATE TYPE "KontoArt" AS ENUM ('BAR', 'BANK', 'RUECKLAGE');
-- CreateEnum
CREATE TYPE "BuchungsArt" AS ENUM ('EINNAHME', 'AUSGABE');
-- CreateEnum
CREATE TYPE "BeitragStand" AS ENUM ('OFFEN', 'BEZAHLT', 'ERLASSEN', 'GEMAHNT');
-- CreateEnum
CREATE TYPE "RechnungStand" AS ENUM ('ENTWURF', 'VERSENDET', 'BEZAHLT', 'GEMAHNT', 'STORNIERT');
-- CreateEnum
CREATE TYPE "RechnungsAnlass" AS ENUM ('FEHLALARM', 'OELEINSATZ', 'VERANSTALTUNG', 'VERMIETUNG', 'SONSTIGES');
-- AlterTable
ALTER TABLE "belegentwurf" ADD COLUMN "buchungId" UUID;
-- CreateTable
CREATE TABLE "kassenkonto" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bezeichnung" VARCHAR(120) NOT NULL,
"art" "KontoArt" NOT NULL DEFAULT 'BAR',
"iban" VARCHAR(34),
"bic" VARCHAR(11),
"anfangsbestand" DECIMAL(12,2) NOT NULL DEFAULT 0,
"bemerkung" VARCHAR(500),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "kassenkonto_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "buchungskategorie" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"name" VARCHAR(120) NOT NULL,
"art" "BuchungsArt" NOT NULL,
"oberkategorieId" UUID,
"kontonummer" VARCHAR(20),
"bemerkung" VARCHAR(500),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "buchungskategorie_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "buchung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"kontoId" UUID NOT NULL,
"kategorieId" UUID NOT NULL,
"datum" DATE NOT NULL,
"art" "BuchungsArt" NOT NULL,
"betrag" DECIMAL(12,2) NOT NULL,
"verwendungszweck" VARCHAR(300) NOT NULL,
"belegnummer" VARCHAR(60),
"personId" UUID,
"lieferant" VARCHAR(200),
"gegenkontoId" UUID,
"belegDateiId" UUID,
"festgeschriebenAm" TIMESTAMPTZ(3),
"storniertVonId" UUID,
"erfasstVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "buchung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "beitragssatz" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"bezeichnung" VARCHAR(120) NOT NULL,
"betrag" DECIMAL(12,2) NOT NULL,
"abteilung" VARCHAR(60),
"abJahr" INTEGER NOT NULL,
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "beitragssatz_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "beitrag" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"personId" UUID NOT NULL,
"jahr" INTEGER NOT NULL,
"satzId" UUID,
"betrag" DECIMAL(12,2) NOT NULL,
"stand" "BeitragStand" NOT NULL DEFAULT 'OFFEN',
"buchungId" UUID,
"bezahltAm" DATE,
"mahnstufe" INTEGER NOT NULL DEFAULT 0,
"gemahntAm" DATE,
"erlassGrund" VARCHAR(300),
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "beitrag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rechnung" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"nummer" VARCHAR(20) NOT NULL,
"anlass" "RechnungsAnlass" NOT NULL DEFAULT 'SONSTIGES',
"stand" "RechnungStand" NOT NULL DEFAULT 'ENTWURF',
"empfaenger" VARCHAR(200) NOT NULL,
"empfaengerZusatz" VARCHAR(200),
"strasse" VARCHAR(200),
"plz" VARCHAR(10),
"ort" VARCHAR(120),
"einsatzId" UUID,
"datum" DATE NOT NULL,
"faelligAm" DATE NOT NULL,
"betrag" DECIMAL(12,2) NOT NULL,
"bemerkung" VARCHAR(1000),
"buchungId" UUID,
"bezahltAm" DATE,
"gemahntAm" DATE,
"mahnstufe" INTEGER NOT NULL DEFAULT 0,
"pdfDateiId" UUID,
"erstelltVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "rechnung_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "rechnungsposition" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"rechnungId" UUID NOT NULL,
"reihenfolge" INTEGER NOT NULL,
"bezeichnung" VARCHAR(300) NOT NULL,
"menge" DECIMAL(12,3) NOT NULL DEFAULT 1,
"einheit" VARCHAR(20),
"einzelpreis" DECIMAL(12,2) NOT NULL,
"gesamtpreis" DECIMAL(12,2) NOT NULL,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "rechnungsposition_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "kassenabschluss" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"vonAm" DATE NOT NULL,
"bisAm" DATE NOT NULL,
"bestandLautBuch" DECIMAL(12,2) NOT NULL,
"gezaehlterBestand" DECIMAL(12,2) NOT NULL,
"differenz" DECIMAL(12,2) NOT NULL,
"erlaeuterung" VARCHAR(2000),
"prueferId" UUID,
"zweiterPrueferId" UUID,
"geprueftAm" DATE,
"berichtDateiId" UUID,
"festgeschriebenAm" TIMESTAMPTZ(3),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "kassenabschluss_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "abschlussposten" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"abschlussId" UUID NOT NULL,
"kontoId" UUID NOT NULL,
"bestandLautBuch" DECIMAL(12,2) NOT NULL,
"gezaehlterBestand" DECIMAL(12,2) NOT NULL,
"differenz" DECIMAL(12,2) NOT NULL,
"bemerkung" VARCHAR(500),
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "abschlussposten_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "kassenkonto_organisationId_aktiv_idx" ON "kassenkonto"("organisationId", "aktiv");
-- CreateIndex
CREATE UNIQUE INDEX "kassenkonto_organisationId_bezeichnung_key" ON "kassenkonto"("organisationId", "bezeichnung");
-- CreateIndex
CREATE INDEX "buchungskategorie_organisationId_art_idx" ON "buchungskategorie"("organisationId", "art");
-- CreateIndex
CREATE UNIQUE INDEX "buchungskategorie_organisationId_name_key" ON "buchungskategorie"("organisationId", "name");
-- CreateIndex
CREATE INDEX "buchung_organisationId_datum_idx" ON "buchung"("organisationId", "datum" DESC);
-- CreateIndex
CREATE INDEX "buchung_organisationId_kontoId_datum_idx" ON "buchung"("organisationId", "kontoId", "datum");
-- CreateIndex
CREATE INDEX "buchung_organisationId_kategorieId_idx" ON "buchung"("organisationId", "kategorieId");
-- CreateIndex
CREATE INDEX "beitragssatz_organisationId_abJahr_idx" ON "beitragssatz"("organisationId", "abJahr");
-- CreateIndex
CREATE UNIQUE INDEX "beitragssatz_organisationId_abteilung_abJahr_key" ON "beitragssatz"("organisationId", "abteilung", "abJahr");
-- CreateIndex
CREATE INDEX "beitrag_organisationId_jahr_stand_idx" ON "beitrag"("organisationId", "jahr", "stand");
-- CreateIndex
CREATE UNIQUE INDEX "beitrag_personId_jahr_key" ON "beitrag"("personId", "jahr");
-- CreateIndex
CREATE INDEX "rechnung_organisationId_stand_idx" ON "rechnung"("organisationId", "stand");
-- CreateIndex
CREATE INDEX "rechnung_organisationId_datum_idx" ON "rechnung"("organisationId", "datum" DESC);
-- CreateIndex
CREATE UNIQUE INDEX "rechnung_organisationId_nummer_key" ON "rechnung"("organisationId", "nummer");
-- CreateIndex
CREATE INDEX "rechnungsposition_organisationId_idx" ON "rechnungsposition"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "rechnungsposition_rechnungId_reihenfolge_key" ON "rechnungsposition"("rechnungId", "reihenfolge");
-- CreateIndex
CREATE INDEX "kassenabschluss_organisationId_bisAm_idx" ON "kassenabschluss"("organisationId", "bisAm" DESC);
-- CreateIndex
CREATE INDEX "abschlussposten_organisationId_idx" ON "abschlussposten"("organisationId");
-- CreateIndex
CREATE UNIQUE INDEX "abschlussposten_abschlussId_kontoId_key" ON "abschlussposten"("abschlussId", "kontoId");
-- AddForeignKey
ALTER TABLE "belegentwurf" ADD CONSTRAINT "belegentwurf_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kassenkonto" ADD CONSTRAINT "kassenkonto_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchungskategorie" ADD CONSTRAINT "buchungskategorie_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchungskategorie" ADD CONSTRAINT "buchungskategorie_oberkategorieId_fkey" FOREIGN KEY ("oberkategorieId") REFERENCES "buchungskategorie"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_kontoId_fkey" FOREIGN KEY ("kontoId") REFERENCES "kassenkonto"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_kategorieId_fkey" FOREIGN KEY ("kategorieId") REFERENCES "buchungskategorie"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_erfasstVonId_fkey" FOREIGN KEY ("erfasstVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_belegDateiId_fkey" FOREIGN KEY ("belegDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_storniertVonId_fkey" FOREIGN KEY ("storniertVonId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "beitragssatz" ADD CONSTRAINT "beitragssatz_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_satzId_fkey" FOREIGN KEY ("satzId") REFERENCES "beitragssatz"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_buchungId_fkey" FOREIGN KEY ("buchungId") REFERENCES "buchung"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_pdfDateiId_fkey" FOREIGN KEY ("pdfDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_erstelltVonId_fkey" FOREIGN KEY ("erstelltVonId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnungsposition" ADD CONSTRAINT "rechnungsposition_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "rechnungsposition" ADD CONSTRAINT "rechnungsposition_rechnungId_fkey" FOREIGN KEY ("rechnungId") REFERENCES "rechnung"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_prueferId_fkey" FOREIGN KEY ("prueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_zweiterPrueferId_fkey" FOREIGN KEY ("zweiterPrueferId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "kassenabschluss_berichtDateiId_fkey" FOREIGN KEY ("berichtDateiId") REFERENCES "datei"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_organisationId_fkey" FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_abschlussId_fkey" FOREIGN KEY ("abschlussId") REFERENCES "kassenabschluss"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "abschlussposten" ADD CONSTRAINT "abschlussposten_kontoId_fkey" FOREIGN KEY ("kontoId") REFERENCES "kassenkonto"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,306 @@
-- =============================================================================
-- Phase 9 — Mandantentrennung und die Regeln, die eine Kasse prüffähig machen
--
-- Drei Dinge stehen hier und nicht im Anwendungscode, weil sie auch dann
-- gelten müssen, wenn jemand an der Anwendung vorbei schreibt:
--
-- 1. **Eine festgeschriebene Buchung ist unveränderbar.** Ein Kassenbuch,
-- in dem man rückwirkend ändern kann, ist vor der Kassenprüfung wertlos.
-- 2. **Ein festgeschriebener Abschluss friert seinen Zeitraum ein.** Sonst
-- hätte der geprüfte Bestand am Tag nach der Prüfung eine andere Zahl.
-- 3. **Rechnerisch Unmögliches wird abgewiesen** — negative Beträge, ein
-- Ende vor dem Beginn, eine Buchung, die gleichzeitig an ein Mitglied und
-- an einen Lieferanten geht.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security
-- -----------------------------------------------------------------------------
DO $$
DECLARE
tabelle text;
BEGIN
FOREACH tabelle IN ARRAY ARRAY[
'kassenkonto',
'buchungskategorie',
'buchung',
'beitragssatz',
'beitrag',
'rechnung',
'rechnungsposition',
'kassenabschluss',
'abschlussposten'
]
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tabelle);
EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', tabelle);
EXECUTE format('DROP POLICY IF EXISTS %I ON %I', tabelle || '_mandant', 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 $$;
-- -----------------------------------------------------------------------------
-- Prüfbedingungen
-- -----------------------------------------------------------------------------
-- Der Betrag ist positiv; die Richtung steht in "art". Eine Buchung über
-- minus zwölf Euro wäre eine zweite Art, dasselbe auszudrücken — und die
-- Summenformel müsste dann beide beherrschen.
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_betrag_positiv" CHECK ("betrag" > 0);
-- Entweder ein Mitglied oder ein Dritter. Beides zusammen wäre nicht
-- auswertbar: bei „wer hat wie viel bekommen" stünde derselbe Betrag zweimal.
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_gegenueber_eindeutig" CHECK (
NOT ("personId" IS NOT NULL AND "lieferant" IS NOT NULL)
);
-- Eine Umbuchung auf dasselbe Konto ist keine.
ALTER TABLE "buchung" ADD CONSTRAINT "buchung_gegenkonto_verschieden" CHECK (
"gegenkontoId" IS NULL OR "gegenkontoId" <> "kontoId"
);
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_betrag_nicht_negativ" CHECK ("betrag" >= 0);
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_jahr_plausibel" CHECK (
"jahr" BETWEEN 1900 AND 2200
);
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_mahnstufe_plausibel" CHECK (
"mahnstufe" BETWEEN 0 AND 5
);
-- Ein erlassener Beitrag ohne Begründung ist in der Kassenprüfung eine offene
-- Frage. Lieber gleich hier verlangen als hinterher erklären.
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_erlass_begruendet" CHECK (
"stand" <> 'ERLASSEN' OR "erlassGrund" IS NOT NULL
);
-- Bezahlt heißt: es gibt einen Tag, an dem gezahlt wurde.
ALTER TABLE "beitrag" ADD CONSTRAINT "beitrag_bezahlt_datiert" CHECK (
"stand" <> 'BEZAHLT' OR "bezahltAm" IS NOT NULL
);
ALTER TABLE "beitragssatz" ADD CONSTRAINT "beitragssatz_betrag_nicht_negativ" CHECK (
"betrag" >= 0
);
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_betrag_nicht_negativ" CHECK ("betrag" >= 0);
ALTER TABLE "rechnung" ADD CONSTRAINT "rechnung_faellig_nach_datum" CHECK (
"faelligAm" >= "datum"
);
ALTER TABLE "rechnungsposition" ADD CONSTRAINT "position_menge_positiv" CHECK ("menge" > 0);
ALTER TABLE "rechnungsposition" ADD CONSTRAINT "position_preis_nicht_negativ" CHECK (
"einzelpreis" >= 0 AND "gesamtpreis" >= 0
);
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_zeitraum_sinnvoll" CHECK (
"bisAm" >= "vonAm"
);
-- Die Differenz ist keine freie Eingabe, sondern das Ergebnis. Wer sie von
-- Hand setzt, verdeckt genau das, was die Prüfung finden soll.
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_differenz_stimmt" CHECK (
"differenz" = "gezaehlterBestand" - "bestandLautBuch"
);
ALTER TABLE "abschlussposten" ADD CONSTRAINT "posten_differenz_stimmt" CHECK (
"differenz" = "gezaehlterBestand" - "bestandLautBuch"
);
-- Ein festgeschriebener Abschluss braucht Prüfer und Datum. Ohne beides ist
-- er kein Abschluss, sondern eine Notiz.
ALTER TABLE "kassenabschluss" ADD CONSTRAINT "abschluss_pruefung_vollstaendig" CHECK (
"festgeschriebenAm" IS NULL
OR ("prueferId" IS NOT NULL AND "geprueftAm" IS NOT NULL)
);
-- -----------------------------------------------------------------------------
-- Suche und Auswertung
-- -----------------------------------------------------------------------------
-- Der Verwendungszweck ist das Feld, in dem gesucht wird — „wo war noch die
-- Rechnung von der Werkstatt".
CREATE INDEX IF NOT EXISTS "buchung_suche_zweck"
ON "buchung" USING gin ("verwendungszweck" gin_trgm_ops);
CREATE INDEX IF NOT EXISTS "buchung_suche_lieferant"
ON "buchung" USING gin ("lieferant" gin_trgm_ops);
-- Offene Posten: die Liste, die der Kassier vor der Jahreshauptversammlung
-- braucht.
CREATE INDEX IF NOT EXISTS "beitrag_offen"
ON "beitrag" ("organisationId", "jahr")
WHERE "stand" IN ('OFFEN', 'GEMAHNT');
CREATE INDEX IF NOT EXISTS "rechnung_offen"
ON "rechnung" ("organisationId", "faelligAm")
WHERE "stand" IN ('VERSENDET', 'GEMAHNT');
-- Noch nicht festgeschriebene Buchungen — der Stapel, den jemand durchsieht.
CREATE INDEX IF NOT EXISTS "buchung_offen"
ON "buchung" ("organisationId", "datum")
WHERE "festgeschriebenAm" IS NULL;
-- -----------------------------------------------------------------------------
-- Rechte
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app';
-- Ein festgeschriebener Abschluss lässt sich nicht wegräumen. Das
-- Löschen einzelner Buchungen bleibt möglich, solange sie nicht
-- festgeschrieben sind — den Rest erledigt der Trigger unten, weil
-- ein pauschales REVOKE auch den Entwurfsstapel unlöschbar machen
-- würde.
EXECUTE 'REVOKE DELETE ON "kassenabschluss" FROM florian_app';
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- Eine festgeschriebene Buchung ist unveränderbar
-- -----------------------------------------------------------------------------
-- Änderbar bleibt genau eines: der Verweis auf die Buchung, die diese hier
-- aufhebt. Eine Stornierung entsteht ja erst später — sie einzutragen ist
-- keine Änderung des Vorgangs, sondern seine Fortschreibung.
CREATE OR REPLACE FUNCTION buchung_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'DELETE' THEN
-- Die eine Ausnahme ist der Aufbewahrungslauf, derselbe Schalter wie
-- beim Audit-Log und beim Pruefprotokoll. Ohne ihn liesse sich eine
-- Organisation nicht mehr loeschen -- und genau das verlangt die
-- DSGVO, wenn eine Wehr das System verlaesst.
IF coalesce(current_setting('app.audit_bereinigung', true), '') = 'an' THEN
RETURN OLD;
END IF;
IF OLD."festgeschriebenAm" IS NOT NULL THEN
RAISE EXCEPTION
'Diese Buchung ist seit % festgeschrieben und laesst sich nicht loeschen. '
'Korrekturen laufen ueber eine Gegenbuchung.', OLD."festgeschriebenAm";
END IF;
RETURN OLD;
END IF;
IF OLD."festgeschriebenAm" IS NULL THEN
RETURN NEW;
END IF;
IF NEW."datum" IS DISTINCT FROM OLD."datum"
OR NEW."betrag" IS DISTINCT FROM OLD."betrag"
OR NEW."art" IS DISTINCT FROM OLD."art"
OR NEW."kontoId" IS DISTINCT FROM OLD."kontoId"
OR NEW."kategorieId" IS DISTINCT FROM OLD."kategorieId"
OR NEW."verwendungszweck" IS DISTINCT FROM OLD."verwendungszweck"
OR NEW."personId" IS DISTINCT FROM OLD."personId"
OR NEW."lieferant" IS DISTINCT FROM OLD."lieferant"
OR NEW."belegnummer" IS DISTINCT FROM OLD."belegnummer"
OR NEW."festgeschriebenAm" IS DISTINCT FROM OLD."festgeschriebenAm" THEN
RAISE EXCEPTION
'Diese Buchung ist seit % festgeschrieben. Korrekturen laufen ueber eine '
'Gegenbuchung mit Verweis, nie durch Aendern.', OLD."festgeschriebenAm";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS buchung_unveraenderlich_trigger ON "buchung";
CREATE TRIGGER buchung_unveraenderlich_trigger
BEFORE UPDATE OR DELETE ON "buchung"
FOR EACH ROW
EXECUTE FUNCTION buchung_unveraenderlich();
-- -----------------------------------------------------------------------------
-- Ein festgeschriebener Abschluss friert seinen Zeitraum ein
-- -----------------------------------------------------------------------------
-- Gilt für neue Buchungen und für das Verschieben vorhandener. Ohne die
-- zweite Hälfte könnte man eine Buchung nachträglich in einen geprüften
-- Zeitraum hineindatieren — und der geprüfte Bestand stimmte nicht mehr.
CREATE OR REPLACE FUNCTION buchung_zeitraum_offen()
RETURNS TRIGGER AS $$
DECLARE
abschluss RECORD;
BEGIN
SELECT "vonAm", "bisAm", "geprueftAm"
INTO abschluss
FROM "kassenabschluss"
WHERE "organisationId" = NEW."organisationId"
AND "festgeschriebenAm" IS NOT NULL
AND NEW."datum" BETWEEN "vonAm" AND "bisAm"
LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION
'Der Zeitraum vom % bis % ist mit dem Kassenabschluss vom % geprueft und '
'abgeschlossen. Fuer eine Korrektur bitte mit heutigem Datum gegenbuchen.',
abschluss."vonAm", abschluss."bisAm", abschluss."geprueftAm";
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS buchung_zeitraum_offen_trigger ON "buchung";
CREATE TRIGGER buchung_zeitraum_offen_trigger
BEFORE INSERT OR UPDATE OF "datum" ON "buchung"
FOR EACH ROW
EXECUTE FUNCTION buchung_zeitraum_offen();
-- -----------------------------------------------------------------------------
-- Ein festgeschriebener Abschluss bleibt, wie er ist
-- -----------------------------------------------------------------------------
-- Aenderbar bleibt genau eines: der Verweis auf den erzeugten Bericht. Das PDF
-- entsteht im Hintergrund und damit erst nach dem Festschreiben — es
-- nachzutragen ist keine Aenderung des Abschlusses, sondern seine
-- Fertigstellung. Dieselbe Ausnahme wie beim Stornierungsverweis der Buchung.
CREATE OR REPLACE FUNCTION abschluss_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
IF OLD."festgeschriebenAm" IS NULL THEN
RETURN NEW;
END IF;
IF NEW."vonAm" IS NOT DISTINCT FROM OLD."vonAm"
AND NEW."bisAm" IS NOT DISTINCT FROM OLD."bisAm"
AND NEW."bestandLautBuch" IS NOT DISTINCT FROM OLD."bestandLautBuch"
AND NEW."gezaehlterBestand" IS NOT DISTINCT FROM OLD."gezaehlterBestand"
AND NEW."differenz" IS NOT DISTINCT FROM OLD."differenz"
AND NEW."erlaeuterung" IS NOT DISTINCT FROM OLD."erlaeuterung"
AND NEW."prueferId" IS NOT DISTINCT FROM OLD."prueferId"
AND NEW."zweiterPrueferId" IS NOT DISTINCT FROM OLD."zweiterPrueferId"
AND NEW."geprueftAm" IS NOT DISTINCT FROM OLD."geprueftAm"
AND NEW."festgeschriebenAm" IS NOT DISTINCT FROM OLD."festgeschriebenAm" THEN
RETURN NEW;
END IF;
RAISE EXCEPTION
'Der Kassenabschluss vom % ist festgeschrieben. Zahlen und Pruefer lassen sich '
'nicht mehr aendern.', OLD."geprueftAm";
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS abschluss_unveraenderlich_trigger ON "kassenabschluss";
CREATE TRIGGER abschluss_unveraenderlich_trigger
BEFORE UPDATE ON "kassenabschluss"
FOR EACH ROW
EXECUTE FUNCTION abschluss_unveraenderlich();
@@ -0,0 +1,189 @@
-- =============================================================================
-- Phase 10 — Mandantenfähigkeit: Konto und Mitgliedschaft
--
-- Bis hierher war die Personenakte zugleich das Anmeldekonto: Passwort-Hash
-- und OIDC-Subject hingen an `person`, und `person` gehört genau einer Wehr.
-- Ein Mensch konnte damit nicht zu zwei Wehren gehören, ohne zwei Passwörter
-- zu haben — und die Anmeldung per E-Mail war mehrdeutig, sobald dieselbe
-- Adresse in zwei Wehren stand (die Eindeutigkeit galt nur je Organisation).
--
-- Ab jetzt:
--
-- benutzer das Konto. Global eindeutige E-Mail, ein Passwort.
-- mitgliedschaft die Zugehörigkeit zu einer Wehr, mit oder ohne Akte.
-- person bleibt die Akte je Wehr — ohne Anmeldedaten.
--
-- DIESE MIGRATION IST DATENERHALTEND. Jede Person mit Anmeldedaten bekommt
-- ein Konto und eine Mitgliedschaft, bevor die Spalten fallen. Wer schon
-- heute in zwei Wehren mit derselben E-Mail steht, bekommt EIN Konto mit ZWEI
-- Mitgliedschaften — also genau das, was vorher nicht möglich war.
--
-- HINWEIS zu den entfernten DropIndex-Bloecken: Prisma schlaegt bei jeder
-- Migration erneut vor, die von Hand angelegten GiST- und Trigram-Indizes aus
-- Phase 1 abzuraeumen. Es kennt sie nicht, weil sie nicht aus dem Schema
-- stammen. Zehnte Migration in Folge, in der sie herausgenommen werden.
-- =============================================================================
-- CreateEnum
CREATE TYPE "MitgliedschaftArt" AS ENUM ('MITGLIED', 'AUFSICHT');
-- CreateTable
CREATE TABLE "benutzer" (
"id" UUID NOT NULL,
"email" VARCHAR(200) NOT NULL,
"passwortHash" VARCHAR(120),
"passwortGeaendertAm" TIMESTAMPTZ(3),
"oidcSubject" VARCHAR(255),
"oidcGruppen" TEXT[] DEFAULT ARRAY[]::TEXT[],
"letzteAnmeldung" TIMESTAMPTZ(3),
"fehlversuche" INTEGER NOT NULL DEFAULT 0,
"gesperrtBis" TIMESTAMPTZ(3),
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"istVerwalter" BOOLEAN NOT NULL DEFAULT false,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
"geloeschtAm" TIMESTAMPTZ(3),
CONSTRAINT "benutzer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "mitgliedschaft" (
"id" UUID NOT NULL,
"organisationId" UUID NOT NULL,
"benutzerId" UUID NOT NULL,
"personId" UUID,
"art" "MitgliedschaftArt" NOT NULL DEFAULT 'MITGLIED',
"aktiv" BOOLEAN NOT NULL DEFAULT true,
"vergebenVonId" UUID,
"erstelltAm" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"geaendertAm" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "mitgliedschaft_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "benutzer_email_key" ON "benutzer"("email");
CREATE UNIQUE INDEX "benutzer_oidcSubject_key" ON "benutzer"("oidcSubject");
CREATE INDEX "benutzer_oidcSubject_idx" ON "benutzer"("oidcSubject");
CREATE UNIQUE INDEX "mitgliedschaft_benutzerId_organisationId_key"
ON "mitgliedschaft"("benutzerId", "organisationId");
CREATE UNIQUE INDEX "mitgliedschaft_personId_key" ON "mitgliedschaft"("personId");
CREATE INDEX "mitgliedschaft_organisationId_art_idx"
ON "mitgliedschaft"("organisationId", "art");
-- AddForeignKey
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_organisationId_fkey"
FOREIGN KEY ("organisationId") REFERENCES "organisation"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_benutzerId_fkey"
FOREIGN KEY ("benutzerId") REFERENCES "benutzer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_personId_fkey"
FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- -----------------------------------------------------------------------------
-- Die Umstellung der vorhandenen Anmeldungen
-- -----------------------------------------------------------------------------
-- Ein Konto je E-Mail-Adresse, über alle Wehren hinweg.
--
-- Angelegt wird nur für Personen, die sich überhaupt anmelden konnten: mit
-- Passwort-Hash oder OIDC-Subject. Eine Akte ohne beides ist eine Akte und
-- kein Konto — dabei bleibt es.
--
-- `DISTINCT ON (lower(email))` entscheidet bei mehreren Akten mit derselben
-- Adresse: die älteste gewinnt und stellt die Anmeldedaten. Die anderen
-- werden gleich darunter zu weiteren Mitgliedschaften desselben Kontos.
INSERT INTO "benutzer" (
"id", "email", "passwortHash", "passwortGeaendertAm",
"oidcSubject", "oidcGruppen", "letzteAnmeldung", "fehlversuche",
"gesperrtBis", "aktiv", "istVerwalter", "erstelltAm", "geaendertAm"
)
SELECT DISTINCT ON (lower(p."email"))
gen_random_uuid(),
lower(p."email"),
p."passwortHash",
p."passwortGeaendertAm",
p."oidcSubject",
p."oidcGruppen",
p."letzteAnmeldung",
p."fehlversuche",
p."gesperrtBis",
p."aktiv",
-- Wer bislang SUPERADMIN war, verwaltet ab jetzt die Installation. Das
-- ist der einzige Weg, auf dem dieses Merkmal je gesetzt wird, ohne dass
-- ein Mensch es vergibt.
EXISTS (
SELECT 1
FROM "person_rolle" pr
JOIN "rolle" r ON r."id" = pr."rolleId"
WHERE pr."personId" = p."id"
AND r."schluessel" = 'SUPERADMIN'
),
p."erstelltAm",
now()
FROM "person" p
WHERE p."email" IS NOT NULL
AND p."geloeschtAm" IS NULL
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL)
ORDER BY lower(p."email"), p."erstelltAm" ASC;
-- Je Akte eine Mitgliedschaft am passenden Konto.
--
-- Auch für die Akten, die beim Anlegen der Konten übergangen wurden: genau
-- sie sind die Doppelmitgliedschaften, um die es in dieser Phase geht.
INSERT INTO "mitgliedschaft" (
"id", "organisationId", "benutzerId", "personId", "art", "aktiv",
"erstelltAm", "geaendertAm"
)
SELECT
gen_random_uuid(),
p."organisationId",
b."id",
p."id",
'MITGLIED',
p."aktiv",
p."erstelltAm",
now()
FROM "person" p
JOIN "benutzer" b ON b."email" = lower(p."email")
WHERE p."email" IS NOT NULL
AND p."geloeschtAm" IS NULL
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL);
-- Ein zweites Konto in derselben Wehr kann es nach der Umstellung nicht
-- geben — der eindeutige Index oben hätte es verhindert. Diese Prüfung ist
-- die Gegenprobe: schlägt sie an, ist etwas an den Annahmen falsch, und die
-- Migration bricht ab, statt einen halben Zustand zu hinterlassen.
DO $$
DECLARE
ohne_konto INTEGER;
BEGIN
SELECT count(*) INTO ohne_konto
FROM "person" p
WHERE p."geloeschtAm" IS NULL
AND (p."passwortHash" IS NOT NULL OR p."oidcSubject" IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM "mitgliedschaft" m WHERE m."personId" = p."id");
IF ohne_konto > 0 THEN
RAISE EXCEPTION
'Umstellung unvollstaendig: % Personen mit Anmeldedaten haben kein Konto '
'bekommen. Vermutlich fehlt ihnen die E-Mail-Adresse.', ohne_konto;
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- Erst jetzt fallen die alten Spalten
-- -----------------------------------------------------------------------------
DROP INDEX IF EXISTS "person_organisationId_oidcSubject_key";
DROP INDEX IF EXISTS "person_oidcSubject_idx";
ALTER TABLE "person"
DROP COLUMN "passwortHash",
DROP COLUMN "passwortGeaendertAm",
DROP COLUMN "oidcSubject",
DROP COLUMN "oidcGruppen",
DROP COLUMN "letzteAnmeldung",
DROP COLUMN "fehlversuche",
DROP COLUMN "gesperrtBis";
@@ -0,0 +1,153 @@
-- =============================================================================
-- Phase 10 — Mandantenfähigkeit: Absicherung
--
-- Zwei Tabellen, zwei sehr verschiedene Schutzbedürfnisse.
--
-- `mitgliedschaft` trägt `organisationId` und wird behandelt wie jede andere
-- Tabelle: Row Level Security, Policy auf die aktuelle Organisation. Wer in
-- Wehr A angemeldet ist, sieht die Mitgliedschaften von Wehr A — und damit
-- übrigens auch nicht, in welchen anderen Wehren ein Kollege noch geführt
-- wird. Das ist Absicht.
--
-- `benutzer` trägt bewusst KEINE `organisationId` und kann es nicht: sie ist
-- der Angelpunkt, an dem ein Mensch zu mehreren Wehren gehört. RLS greift hier
-- also nicht. Stattdessen wird sie über Rechte geschützt — dieselbe Lösung wie
-- bei `berechtigung` aus Phase 0, und aus demselben Grund: eine Tabelle, die
-- über allen Mandanten steht, gehört nicht in die Hand der Anwendungsrolle.
-- =============================================================================
-- -----------------------------------------------------------------------------
-- Row Level Security für die Mitgliedschaft
-- -----------------------------------------------------------------------------
ALTER TABLE "mitgliedschaft" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "mitgliedschaft" FORCE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "mitgliedschaft_mandant" ON "mitgliedschaft";
CREATE POLICY "mitgliedschaft_mandant" ON "mitgliedschaft" FOR ALL
USING ("organisationId" = aktuelle_organisation())
WITH CHECK ("organisationId" = aktuelle_organisation());
-- -----------------------------------------------------------------------------
-- Prüfbedingungen
-- -----------------------------------------------------------------------------
-- Eine Mitgliedschaft als MITGLIED braucht die Personenakte — sie ist ja
-- genau die Verbindung zwischen Konto und Wehr. Die Aufsicht darf keine
-- haben: die Kreisebene gehört der Wehr nicht an und hat dort nichts in der
-- Mitgliederliste zu suchen.
ALTER TABLE "mitgliedschaft" ADD CONSTRAINT "mitgliedschaft_akte_passend" CHECK (
("art" = 'MITGLIED' AND "personId" IS NOT NULL)
OR ("art" = 'AUFSICHT' AND "personId" IS NULL)
);
-- Die Akte muss zu derselben Wehr gehören wie die Mitgliedschaft. Ein
-- Fremdschlüssel allein sagt darüber nichts — er prüft nur, dass es die Akte
-- gibt, nicht, wo sie liegt. Ohne diese Bedingung ließe sich ein Konto über
-- eine Akte aus Wehr A in Wehr B hineinhängen, und die Sitzung stünde mit
-- einer personId da, die dort niemandem gehört.
CREATE OR REPLACE FUNCTION mitgliedschaft_akte_gleiche_wehr()
RETURNS TRIGGER AS $$
DECLARE
akte_wehr uuid;
BEGIN
IF NEW."personId" IS NULL THEN
RETURN NEW;
END IF;
SELECT "organisationId" INTO akte_wehr FROM "person" WHERE "id" = NEW."personId";
IF akte_wehr IS DISTINCT FROM NEW."organisationId" THEN
RAISE EXCEPTION
'Die Personenakte gehoert zu einer anderen Wehr als die Mitgliedschaft. '
'Ein Konto wird je Wehr an die dortige Akte gehaengt.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS mitgliedschaft_akte_gleiche_wehr_trigger ON "mitgliedschaft";
CREATE TRIGGER mitgliedschaft_akte_gleiche_wehr_trigger
BEFORE INSERT OR UPDATE OF "personId", "organisationId" ON "mitgliedschaft"
FOR EACH ROW
EXECUTE FUNCTION mitgliedschaft_akte_gleiche_wehr();
-- Eine E-Mail-Adresse ohne @ ist keine. Die Anmeldung hängt daran, und ein
-- Konto, mit dem sich niemand anmelden kann, fällt sonst erst auf, wenn es
-- jemand versucht.
ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_email_plausibel" CHECK (
"email" LIKE '%@%.%' AND length("email") >= 6
);
-- Ein Konto ohne jeden Anmeldeweg wäre eine Karteileiche. Entweder ein
-- Passwort oder ein OIDC-Subject — sonst ist es keines.
ALTER TABLE "benutzer" ADD CONSTRAINT "benutzer_anmeldeweg_vorhanden" CHECK (
"passwortHash" IS NOT NULL OR "oidcSubject" IS NOT NULL
);
-- -----------------------------------------------------------------------------
-- Rechte
-- -----------------------------------------------------------------------------
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') THEN
EXECUTE 'GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO florian_app';
EXECUTE 'GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO florian_app';
EXECUTE 'REVOKE INSERT, UPDATE, DELETE ON "berechtigung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "audit_log" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "kiosk_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "druckmessung" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "atemschutz_ereignis" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "benachrichtigung" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "ki_vorgang" FROM florian_app';
EXECUTE 'REVOKE UPDATE, DELETE ON "wikiversion" FROM florian_app';
EXECUTE 'REVOKE DELETE ON "kassenabschluss" FROM florian_app';
-- Konten legt die Anwendungsrolle nicht an und löscht sie nicht.
--
-- Beides geht über den Systemzugang, also über denselben Weg, über
-- den sich schon heute die Anmeldung eine Person sucht (RLS kennt vor
-- der Anmeldung ja noch keine Organisation). UPDATE bleibt erlaubt,
-- weil jede Anmeldung eines schreibt: letzte Anmeldung, Fehlversuche,
-- Sperre. Ein REVOKE darauf hiesse, dass niemand sich mehr anmelden
-- kann.
EXECUTE 'REVOKE INSERT, DELETE ON "benutzer" FROM florian_app';
END IF;
END $$;
-- -----------------------------------------------------------------------------
-- Ein Konto behält seine Kennung
-- -----------------------------------------------------------------------------
-- Was die Anwendungsrolle am Konto ändern darf, ist genau das, was bei jeder
-- Anmeldung anfällt. Alles Übrige — E-Mail, Passwort, Verwalterrecht —
-- gehört in die Kontenverwaltung und damit an den Systemzugang.
--
-- Ohne diese Sperre wäre `istVerwalter` über jeden Weg erreichbar, der ein
-- Konto schreibt. Und ein Merkmal, das die Mandantentrennung aufhebt, darf
-- nicht dort gesetzt werden können, wo eine Wehr ihre eigenen Daten pflegt.
CREATE OR REPLACE FUNCTION benutzer_kennung_unveraenderlich()
RETURNS TRIGGER AS $$
BEGIN
IF current_user <> 'florian_app' THEN
RETURN NEW;
END IF;
IF NEW."email" IS DISTINCT FROM OLD."email"
OR NEW."istVerwalter" IS DISTINCT FROM OLD."istVerwalter"
OR NEW."oidcSubject" IS DISTINCT FROM OLD."oidcSubject" THEN
RAISE EXCEPTION
'E-Mail, OIDC-Subject und das Verwalterrecht eines Kontos werden ueber die '
'Kontenverwaltung geaendert, nicht aus einer Wehr heraus.';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS benutzer_kennung_unveraenderlich_trigger ON "benutzer";
CREATE TRIGGER benutzer_kennung_unveraenderlich_trigger
BEFORE UPDATE ON "benutzer"
FOR EACH ROW
EXECUTE FUNCTION benutzer_kennung_unveraenderlich();
+572 -15
View File
@@ -107,6 +107,21 @@ enum FahrzeugArt {
}
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
/// Wie jemand zu einer Wehr gehört.
///
/// MITGLIED ist der Regelfall: das Konto hängt an einer Personenakte in dieser
/// Wehr und bringt deren Rollen mit.
///
/// AUFSICHT ist die Kreisebene — Kreisbrandrat, Kreisbrandinspektion. Sie
/// gehört der Wehr nicht an, hat dort also auch keine Personenakte, und darf
/// ausschließlich lesen. Ein Kreisbrandinspektor, der zugleich Mitglied seiner
/// Heimatwehr ist, hat schlicht zwei Mitgliedschaften: dort MITGLIED, in den
/// betreuten Wehren AUFSICHT.
enum MitgliedschaftArt {
MITGLIED
AUFSICHT
}
enum Dienstgrad {
FEUERWEHRANWAERTER
FEUERWEHRMANN
@@ -153,6 +168,7 @@ model Organisation {
geloeschtAm DateTime? @db.Timestamptz(3)
personen Person[]
mitgliedschaften Mitgliedschaft[]
rollen Rolle[]
personRollen PersonRolle[]
rolleRechte RolleBerechtigung[]
@@ -234,6 +250,17 @@ model Organisation {
diktate Diktat[]
belegentwuerfe Belegentwurf[]
// Phase 9 — Kassenverwaltung
kassenkonten Kassenkonto[]
buchungskategorien Buchungskategorie[]
buchungen Buchung[]
beitragssaetze Beitragssatz[]
beitraege Beitrag[]
rechnungen Rechnung[]
rechnungspositionen Rechnungsposition[]
kassenabschluesse Kassenabschluss[]
abschlussposten Abschlussposten[]
@@index([aktiv])
@@map("organisation")
}
@@ -269,17 +296,12 @@ model Person {
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
abteilung String? @db.VarChar(60)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
passwortHash String? @db.VarChar(120)
passwortGeaendertAm DateTime? @db.Timestamptz(3)
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
oidcSubject String? @db.VarChar(255)
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
oidcGruppen String[] @default([])
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
/// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
///
/// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
/// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
/// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
/// und die Anmeldung per E-Mail wäre mehrdeutig.
aktiv Boolean @default(true)
/// Profilbild. Liegt wie alles andere in MinIO.
@@ -290,6 +312,7 @@ model Person {
geloeschtAm DateTime? @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
rollen PersonRolle[] @relation("PersonRollenInhaber")
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
@@ -356,19 +379,112 @@ model Person {
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
/// Person kann in zwei Wehren Mitglied sein.
// Phase 9 — Kassenverwaltung
buchungen Buchung[] @relation("BuchungPerson")
erfassteBuchungen Buchung[] @relation("BuchungErfasser")
beitraege Beitrag[] @relation("BeitragPerson")
gepruefteAbschluesse Kassenabschluss[] @relation("AbschlussPruefer")
zweitgepruefteAbschluesse Kassenabschluss[] @relation("AbschlussZweitpruefer")
erstellteRechnungen Rechnung[] @relation("RechnungErsteller")
/// Die E-Mail ist je Wehr eindeutig, nicht global: dieselbe Person kann in
/// zwei Wehren eine Akte haben. Angemeldet wird sich nicht damit, sondern
/// mit der E-Mail des `Benutzer` — die ist global eindeutig.
@@unique([organisationId, email])
@@unique([organisationId, oidcSubject])
@@unique([organisationId, mitgliedsnummer])
@@index([organisationId, nachname, vorname])
@@index([organisationId, aktiv])
@@index([oidcSubject])
@@map("person")
}
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
/// Rollen kann der Kommandant ergänzen.
/// Ein Anmeldekonto.
///
/// **Die einzige Tabelle neben `Berechtigung` ohne `organisationId`** — und
/// zwar notwendigerweise: sie ist der Angelpunkt, an dem ein Mensch zu
/// mehreren Wehren gehören kann. Ein Konto mit Mandantenkennung könnte genau
/// das nicht.
///
/// Sie ist deshalb auch nicht über Row Level Security geschützt, sondern über
/// Rechte: die Anwendungsrolle darf sie nur lesen und die Anmeldefelder
/// fortschreiben, angelegt und verändert werden Konten über den
/// Systemzugang. Was ein Konto in einer Wehr sehen darf, entscheidet nicht es
/// selbst, sondern seine `Mitgliedschaft` — und die trägt `organisationId`
/// wie jede andere Tabelle.
model Benutzer {
id String @id @default(uuid()) @db.Uuid
/// Global eindeutig. Das ist der Unterschied zur E-Mail an der Personenakte:
/// die ist eine Kontaktangabe je Wehr, diese hier ist die Anmeldung.
email String @unique @db.VarChar(200)
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
passwortHash String? @db.VarChar(120)
passwortGeaendertAm DateTime? @db.Timestamptz(3)
/// "sub"-Claim aus authentik.
oidcSubject String? @unique @db.VarChar(255)
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
oidcGruppen String[] @default([])
letzteAnmeldung DateTime? @db.Timestamptz(3)
fehlversuche Int @default(0)
gesperrtBis DateTime? @db.Timestamptz(3)
aktiv Boolean @default(true)
/// Verwaltet die Installation: Wehren anlegen und in jede hineinschalten.
///
/// Bewusst ein Merkmal des Kontos und keine Rolle innerhalb einer Wehr. Eine
/// Rolle gilt je Organisation — wer Wehren anlegt, steht aber über allen,
/// und ein Recht, das in Wehr A vergeben wird und in Wehr B wirkt, wäre
/// genau die Art Hintertür, die die Mandantentrennung aushöhlt.
istVerwalter Boolean @default(false)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
mitgliedschaften Mitgliedschaft[]
@@index([oidcSubject])
@@map("benutzer")
}
/// Die Zugehörigkeit eines Kontos zu einer Wehr.
///
/// Sie ist das, was eine Sitzung ausmacht: erst die Wahl einer Mitgliedschaft
/// entscheidet, welche Wehr in `app.current_organisation` steht und welche
/// Rollen gelten. Wer nur eine hat — die allermeisten —, merkt davon nichts.
model Mitgliedschaft {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
benutzerId String @db.Uuid
/// Die Personenakte in dieser Wehr. Bei AUFSICHT leer: die Kreisebene ist
/// dort nicht Mitglied und soll auch in keiner Mitgliederliste auftauchen.
personId String? @db.Uuid
art MitgliedschaftArt @default(MITGLIED)
aktiv Boolean @default(true)
/// Wer sie eingerichtet hat. Bei der Umstellung aus Phase 10 leer.
vergebenVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
benutzer Benutzer @relation(fields: [benutzerId], references: [id], onDelete: Cascade)
person Person? @relation("MitgliedschaftPerson", fields: [personId], references: [id], onDelete: Cascade)
/// Ein Konto gehört einer Wehr höchstens einmal an.
@@unique([benutzerId, organisationId])
/// Und eine Personenakte hängt an höchstens einem Konto — sonst wäre nicht
/// entscheidbar, wer unter ihrem Namen gehandelt hat.
@@unique([personId])
@@index([organisationId, art])
@@map("mitgliedschaft")
}
model Rolle {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
@@ -602,6 +718,11 @@ model Datei {
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
// Phase 9
alsBuchungsbeleg Buchung[] @relation("BuchungBeleg")
alsRechnungsPdf Rechnung[] @relation("RechnungPdf")
alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht")
@@unique([bucket, objektSchluessel])
@@index([organisationId, entitaet, entitaetId])
@@index([organisationId, kategorie])
@@ -3601,7 +3722,11 @@ model Belegentwurf {
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
geloeschtAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die daraus geworden ist (Phase 9). Leer heißt: noch keine.
buchungId String? @db.Uuid
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
@@ -3611,3 +3736,435 @@ model Belegentwurf {
@@index([organisationId, belegdatum])
@@map("belegentwurf")
}
// =============================================================================
// Phase 9 — Kassenverwaltung
//
// Drei Festlegungen bestimmen alles Weitere. Sie stehen hier, weil sie sich
// im Schema abbilden und nicht bloß in der Anwendung durchgesetzt werden:
//
// 1. **Beträge sind DECIMAL(12,2), niemals Fließkomma.** 0,1 + 0,2 ist in
// binärer Fließkommadarstellung nicht 0,3, und eine Kasse, die sich um
// Cent verrechnet, ist keine Kasse. Zwölf Stellen reichen für zehn
// Milliarden — mehr als jede Wehr je bewegt.
//
// 2. **Eine festgeschriebene Buchung ist unveränderbar.** Korrekturen laufen
// über eine Gegenbuchung mit Verweis auf das Original. Das ist keine
// Förmlichkeit: ein Kassenbuch, in dem man rückwirkend ändern kann, ist
// vor der Kassenprüfung wertlos. Der Trigger in der Migration setzt es
// durch.
//
// 3. **Der Kassenabschluss friert seinen Zeitraum ein.** Danach nimmt die
// Buchungsmaske kein Datum daraus mehr an. Sonst hätte der geprüfte
// Bestand am Tag nach der Prüfung eine andere Zahl.
//
// Der Betrag ist immer **positiv**; die Richtung steht in `art`. Ein
// vorzeichenbehafteter Betrag wäre kürzer, aber jede Auswertung müsste dann
// darauf vertrauen, dass das Vorzeichen zur Kategorie passt — und beim ersten
// Tippfehler stimmt die Summe nicht mehr.
// =============================================================================
/// Welche Art von Konto.
enum KontoArt {
/// Die Barkasse im Gerätehaus. Wird bei der Prüfung gezählt.
BAR
/// Ein Bankkonto. Wird gegen den Auszug geprüft.
BANK
/// Rücklage für eine bestimmte Anschaffung — getrennt geführt, damit
/// niemand versehentlich das Geld für das neue Fahrzeug verbraucht.
RUECKLAGE
}
/// Geht Geld ein oder hinaus.
enum BuchungsArt {
EINNAHME
AUSGABE
}
/// Wie es um einen Beitrag steht.
enum BeitragStand {
OFFEN
BEZAHLT
/// Erlassen — bei Härtefällen, Ehrenmitgliedern, Jugendlichen.
ERLASSEN
GEMAHNT
}
/// Wie es um eine Rechnung steht.
enum RechnungStand {
ENTWURF
VERSENDET
BEZAHLT
GEMAHNT
STORNIERT
}
/// Woraus eine Rechnung an Dritte entsteht.
enum RechnungsAnlass {
/// Böswilliger oder fahrlässig ausgelöster Fehlalarm.
FEHLALARM
/// Ölspur, auslaufende Betriebsstoffe — nach Verursacherprinzip.
OELEINSATZ
/// Absicherung, Brandsicherheitswache bei einer Veranstaltung.
VERANSTALTUNG
/// Vermietung von Gerät oder Räumen.
VERMIETUNG
SONSTIGES
}
/// Ein Konto der Wehr.
model Kassenkonto {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
art KontoArt @default(BAR)
/// Nur bei Bankkonten. Wird in Auswertungen nie vollständig angezeigt.
iban String? @db.VarChar(34)
bic String? @db.VarChar(11)
/// Der Bestand zu Beginn der Aufzeichnung. Alles Weitere ergibt sich aus
/// den Buchungen — er wird nie nachträglich „korrigiert".
anfangsbestand Decimal @default(0) @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
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)
buchungen Buchung[]
posten Abschlussposten[]
@@unique([organisationId, bezeichnung])
@@index([organisationId, aktiv])
@@map("kassenkonto")
}
/// Wofür Geld ausgegeben oder eingenommen wurde.
///
/// Zweistufig: eine Oberkategorie („Fahrzeuge") mit Unterkategorien
/// („Kraftstoff", „Reparatur"). Mehr Ebenen braucht eine Wehr nicht, und jede
/// weitere macht die Auswertung unlesbar.
model Buchungskategorie {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
name String @db.VarChar(120)
art BuchungsArt
/// Die Oberkategorie. Leer heißt: das hier ist eine.
oberkategorieId String? @db.Uuid
/// Nummer im Kontenrahmen der Gemeinde — für den Export an die Kämmerei.
kontonummer String? @db.VarChar(20)
bemerkung String? @db.VarChar(500)
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)
oberkategorie Buchungskategorie? @relation("Kategoriebaum", fields: [oberkategorieId], references: [id], onDelete: SetNull)
unterkategorien Buchungskategorie[] @relation("Kategoriebaum")
buchungen Buchung[]
@@unique([organisationId, name])
@@index([organisationId, art])
@@map("buchungskategorie")
}
/// Eine Buchung.
///
/// Der Betrag ist positiv, die Richtung steht in `art`. Festgeschrieben heißt:
/// unveränderbar und nicht löschbar — die Datenbank hält dagegen. Wer sich
/// vertan hat, bucht dagegen; die Gegenbuchung verweist auf das Original, und
/// beide bleiben stehen.
model Buchung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
kontoId String @db.Uuid
kategorieId String @db.Uuid
/// Das Buchungsdatum, nicht der Zeitpunkt der Erfassung. Danach richtet
/// sich, in welchen Abschluss die Buchung fällt.
datum DateTime @db.Date
art BuchungsArt
betrag Decimal @db.Decimal(12, 2)
verwendungszweck String @db.VarChar(300)
belegnummer String? @db.VarChar(60)
/// Wer das Geld bekommen oder gegeben hat. Entweder eine Person der Wehr
/// **oder** ein Dritter als Text — nie beides.
personId String? @db.Uuid
lieferant String? @db.VarChar(200)
/// Bei einer Umbuchung zwischen zwei eigenen Konten das Gegenkonto.
gegenkontoId String? @db.Uuid
belegDateiId String? @db.Uuid
/// Festgeschrieben: ab hier unveränderbar.
festgeschriebenAm DateTime? @db.Timestamptz(3)
/// Die Buchung, die diese hier aufhebt — und umgekehrt.
storniertVonId String? @db.Uuid
erfasstVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
kategorie Buchungskategorie @relation(fields: [kategorieId], references: [id], onDelete: Restrict)
person Person? @relation("BuchungPerson", fields: [personId], references: [id], onDelete: SetNull)
erfasstVon Person? @relation("BuchungErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
belegDatei Datei? @relation("BuchungBeleg", fields: [belegDateiId], references: [id], onDelete: SetNull)
storniertVon Buchung? @relation("Stornierung", fields: [storniertVonId], references: [id], onDelete: SetNull)
storniert Buchung[] @relation("Stornierung")
beitraege Beitrag[]
rechnungen Rechnung[]
belegentwurf Belegentwurf[]
@@index([organisationId, datum(sort: Desc)])
@@index([organisationId, kontoId, datum])
@@index([organisationId, kategorieId])
@@map("buchung")
}
/// Was ein Mitglied im Jahr zahlt.
///
/// Je Abteilung ein Satz: Aktive zahlen anderes als Jugend, Ehrenmitglieder
/// oft nichts. Der Satz gilt ab einem Jahr und wird nicht rückwirkend
/// geändert — sonst stimmen die Beiträge vergangener Jahre nicht mehr.
model Beitragssatz {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
bezeichnung String @db.VarChar(120)
betrag Decimal @db.Decimal(12, 2)
/// Für welche Abteilung. Leer heißt: für alle, die keinen eigenen Satz haben.
abteilung String? @db.VarChar(60)
/// Ab welchem Jahr er gilt.
abJahr Int
aktiv Boolean @default(true)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
beitraege Beitrag[]
@@unique([organisationId, abteilung, abJahr])
@@index([organisationId, abJahr])
@@map("beitragssatz")
}
/// Der Beitrag einer Person für ein Jahr.
model Beitrag {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
personId String @db.Uuid
jahr Int
satzId String? @db.Uuid
betrag Decimal @db.Decimal(12, 2)
stand BeitragStand @default(OFFEN)
/// Die Buchung, mit der bezahlt wurde. Erst sie macht aus „bezahlt" eine
/// Zahl in der Kasse.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
/// Wie oft gemahnt wurde. Null heißt: noch nicht.
mahnstufe Int @default(0)
gemahntAm DateTime? @db.Date
/// Bei Erlass: warum. Ein erlassener Beitrag ohne Begründung ist in der
/// Kassenprüfung eine offene Frage.
erlassGrund String? @db.VarChar(300)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
person Person @relation("BeitragPerson", fields: [personId], references: [id], onDelete: Cascade)
satz Beitragssatz? @relation(fields: [satzId], references: [id], onDelete: SetNull)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
@@unique([personId, jahr])
@@index([organisationId, jahr, stand])
@@map("beitrag")
}
/// Eine Rechnung an Dritte.
///
/// Fehlalarm, Ölspur, Absicherung einer Veranstaltung. Nummer aus dem
/// Nummernkreis (`JJJJ-NNNN`), PDF über Gotenberg.
model Rechnung {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
nummer String @db.VarChar(20)
anlass RechnungsAnlass @default(SONSTIGES)
stand RechnungStand @default(ENTWURF)
/// An wen. Bewusst als Text und nicht als Verweis: der Empfänger ist in
/// aller Regel niemand, den die Wehr dauerhaft führt.
empfaenger String @db.VarChar(200)
empfaengerZusatz String? @db.VarChar(200)
strasse String? @db.VarChar(200)
plz String? @db.VarChar(10)
ort String? @db.VarChar(120)
/// Worauf sie sich bezieht.
einsatzId String? @db.Uuid
datum DateTime @db.Date
faelligAm DateTime @db.Date
/// Summe der Positionen. Wird beim Speichern gerechnet, nicht eingegeben.
betrag Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(1000)
/// Die Buchung des Zahlungseingangs.
buchungId String? @db.Uuid
bezahltAm DateTime? @db.Date
gemahntAm DateTime? @db.Date
mahnstufe Int @default(0)
pdfDateiId String? @db.Uuid
erstelltVonId String? @db.Uuid
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: SetNull)
pdfDatei Datei? @relation("RechnungPdf", fields: [pdfDateiId], references: [id], onDelete: SetNull)
erstelltVon Person? @relation("RechnungErsteller", fields: [erstelltVonId], references: [id], onDelete: SetNull)
positionen Rechnungsposition[]
@@unique([organisationId, nummer])
@@index([organisationId, stand])
@@index([organisationId, datum(sort: Desc)])
@@map("rechnung")
}
/// Eine Zeile auf einer Rechnung.
model Rechnungsposition {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
rechnungId String @db.Uuid
reihenfolge Int
bezeichnung String @db.VarChar(300)
/// Menge mit drei Nachkommastellen — Einsatzstunden werden viertelstündlich
/// abgerechnet, und 0,25 muss genau sein.
menge Decimal @default(1) @db.Decimal(12, 3)
einheit String? @db.VarChar(20)
einzelpreis Decimal @db.Decimal(12, 2)
/// Menge × Einzelpreis, beim Speichern gerechnet.
gesamtpreis Decimal @db.Decimal(12, 2)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
rechnung Rechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
@@unique([rechnungId, reihenfolge])
@@index([organisationId])
@@map("rechnungsposition")
}
/// Ein Kassenabschluss.
///
/// Der Vorgang, für den es die ganze Kasse gibt: zwei Kassenprüfer setzen sich
/// hin, zählen das Bargeld, vergleichen die Kontoauszüge und unterschreiben.
/// Danach ist der Zeitraum zu — die Buchungsmaske nimmt kein Datum daraus mehr
/// an.
///
/// Zwei Prüfer, weil es die Satzung fast jeder Wehr so vorsieht. Der zweite
/// ist trotzdem optional: eine Wehr, die gerade nur einen hat, soll deshalb
/// nicht ohne Abschluss dastehen.
model Kassenabschluss {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
vonAm DateTime @db.Date
bisAm DateTime @db.Date
/// Summe über alle Konten, aus den Buchungen gerechnet.
bestandLautBuch Decimal @db.Decimal(12, 2)
/// Was tatsächlich gezählt wurde.
gezaehlterBestand Decimal @db.Decimal(12, 2)
/// gezaehlt minus laut Buch. Positiv heißt: es war mehr da.
differenz Decimal @db.Decimal(12, 2)
/// Eine Differenz ohne Erklärung ist der Punkt, an dem eine Kassenprüfung
/// hängen bleibt.
erlaeuterung String? @db.VarChar(2000)
prueferId String? @db.Uuid
zweiterPrueferId String? @db.Uuid
geprueftAm DateTime? @db.Date
berichtDateiId String? @db.Uuid
/// Festgeschrieben: ab hier ist der Zeitraum zu.
festgeschriebenAm DateTime? @db.Timestamptz(3)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
pruefer Person? @relation("AbschlussPruefer", fields: [prueferId], references: [id], onDelete: SetNull)
zweiterPruefer Person? @relation("AbschlussZweitpruefer", fields: [zweiterPrueferId], references: [id], onDelete: SetNull)
berichtDatei Datei? @relation("AbschlussBericht", fields: [berichtDateiId], references: [id], onDelete: SetNull)
posten Abschlussposten[]
@@index([organisationId, bisAm(sort: Desc)])
@@map("kassenabschluss")
}
/// Was auf einem Konto zum Abschluss stand.
///
/// Je Konto eine Zeile: der Kassenprüfer zählt das Bargeld und liest den
/// Kontostand vom Auszug ab. Eine einzelne Summe über alles würde verbergen,
/// auf welchem Konto die Differenz sitzt.
model Abschlussposten {
id String @id @default(uuid()) @db.Uuid
organisationId String @db.Uuid
abschlussId String @db.Uuid
kontoId String @db.Uuid
bestandLautBuch Decimal @db.Decimal(12, 2)
gezaehlterBestand Decimal @db.Decimal(12, 2)
differenz Decimal @db.Decimal(12, 2)
bemerkung String? @db.VarChar(500)
erstelltAm DateTime @default(now()) @db.Timestamptz(3)
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
abschluss Kassenabschluss @relation(fields: [abschlussId], references: [id], onDelete: Cascade)
konto Kassenkonto @relation(fields: [kontoId], references: [id], onDelete: Restrict)
@@unique([abschlussId, kontoId])
@@index([organisationId])
@@map("abschlussposten")
}
+898 -3
View File
@@ -21,6 +21,9 @@
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
* Ab Phase 9 dazu: das Kassenjahr — drei Konten, der Standardkontenrahmen,
* gut dreißig Buchungen über zwei Jahre, die Mitgliedsbeiträge mit Rückständen,
* zwei Rechnungen und die festgeschriebene Kassenprüfung des Vorjahres.
*
* 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
@@ -36,11 +39,14 @@ import {
Alarmierungsart,
AnwesenheitAnlass,
AusweisTokenTyp,
BeitragStand,
BuchungsArt,
ChecklistenpunktTyp,
EinsatzFunktion,
EinsatzKategorie,
EinsatzStatus,
Dienstgrad,
KontoArt,
DruckArt,
EreignisArt,
FahrtZweck,
@@ -51,6 +57,7 @@ import {
LebensdauerBezug,
LebensdauerKategorie,
MangelPrioritaet,
MitgliedschaftArt,
MangelStatus,
PrismaClient,
PruefErgebnis,
@@ -58,6 +65,8 @@ import {
PruefungStatus,
QualifikationsArt,
RolleSchluessel,
RechnungsAnlass,
RechnungStand,
SignaturRolle,
HydrantStatus,
HydrantTyp,
@@ -190,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map<string, string
const rollenKarte = new Map<string, string>();
for (const schluessel of ROLLEN) {
// AUFSICHT ist keine vergebbare Rolle und steht deshalb auch nicht im
// Prisma-Enum. Sie ergibt sich seit Phase 10 daraus, dass ein Konto einer
// Wehr als Kreisaufsicht zugeordnet ist — sie hier anzulegen hiesse, dass
// jemand sie innerhalb einer Wehr vergeben könnte.
const vergebbar = ROLLEN.filter((schluessel) => schluessel !== "AUFSICHT");
for (const schluessel of vergebbar) {
const rolle = await prisma.rolle.upsert({
where: {
organisationId_schluessel: {
@@ -491,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
nachname: "Administrator",
email,
dienstgrad: Dienstgrad.KEIN,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
abteilung: "Verwaltung",
},
});
// Das Konto seit Phase 10 getrennt von der Akte. Die E-Mail ist global
// eindeutig — wird derselbe Seed für eine zweite Wehr gefahren, entsteht
// deshalb kein zweites Konto, sondern eine zweite Mitgliedschaft. Genau so
// sieht Doppelmitgliedschaft in echt aus.
const konto = await prisma.benutzer.upsert({
where: { email },
update: {},
create: {
email,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
istVerwalter: true,
},
});
await prisma.mitgliedschaft.upsert({
where: { benutzerId_organisationId: { benutzerId: konto.id, organisationId } },
update: {},
create: { organisationId, benutzerId: konto.id, personId: admin.id },
});
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
if (rolleId) {
await prisma.personRolle.upsert({
@@ -3247,6 +3281,861 @@ async function wikiAnlegen(organisationId: string): Promise<void> {
);
}
// -----------------------------------------------------------------------------
// Phase 9 — Kassenverwaltung
//
// Ein Kassenjahr, wie es eine Wehr wirklich hat: drei Konten, der
// Standardkontenrahmen, gut dreißig Buchungen über zwei Jahre, die
// Mitgliedsbeiträge, zwei Rechnungen und die abgeschlossene Kassenprüfung
// des Vorjahres.
//
// Die Reihenfolge ist hier nicht beliebig, und das ist kein Zufall des
// Skripts, sondern die Absicherung selbst: sobald der Abschluss für das
// Vorjahr festgeschrieben ist, lehnt ein Trigger jede weitere Buchung mit
// einem Datum in diesem Zeitraum ab. Erst die Buchungen, dann der Abschluss —
// wer es umdreht, bekommt vom Seed dieselbe Fehlermeldung wie der Kassier,
// der drei Wochen nach der Prüfung noch eine Quittung findet.
// -----------------------------------------------------------------------------
async function kassendatenAnlegen(organisationId: string): Promise<void> {
const vorhanden = await prisma.kassenkonto.count({ where: { organisationId } });
if (vorhanden > 0) {
log("Kassendaten bereits vorhanden — übersprungen.");
return;
}
const { STANDARDKATEGORIEN } = await import("../src/lib/kasse/vorschlag");
const jahr = new Date().getUTCFullYear();
const vorjahr = jahr - 1;
// --- Konten ---------------------------------------------------------------
const bar = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Barkasse",
art: KontoArt.BAR,
anfangsbestand: "250.00",
bemerkung: "Kassette im Kommandantenzimmer. Wechselgeld für Feste.",
},
});
const giro = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Girokonto Raiffeisenbank",
art: KontoArt.BANK,
iban: "DE02701500000000594937",
bic: "SSKMDEMM",
anfangsbestand: "4800.00",
},
});
const ruecklage = await prisma.kassenkonto.create({
data: {
organisationId,
bezeichnung: "Rücklage Fahrzeugbeschaffung",
art: KontoArt.RUECKLAGE,
iban: "DE02120300000000202051",
anfangsbestand: "12000.00",
bemerkung: "Angespart für den Ersatz des MTW. Wird nicht laufend bewegt.",
},
});
// --- Kontenrahmen ---------------------------------------------------------
//
// Zwei Durchgänge: erst die Oberkategorien, dann die Unterkategorien mit
// ihrem Verweis. Anders ginge es nicht, der Baum verweist auf sich selbst.
const kategorien = new Map<string, string>();
for (const eintrag of STANDARDKATEGORIEN.filter((k) => !k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: { organisationId, name: eintrag.name, art: eintrag.art },
});
kategorien.set(eintrag.name, angelegt.id);
}
for (const eintrag of STANDARDKATEGORIEN.filter((k) => k.oberkategorie)) {
const angelegt = await prisma.buchungskategorie.create({
data: {
organisationId,
name: eintrag.name,
art: eintrag.art,
oberkategorieId: kategorien.get(eintrag.oberkategorie!) ?? null,
},
});
kategorien.set(eintrag.name, angelegt.id);
}
const kategorie = (name: string): string => {
const id = kategorien.get(name);
if (!id) throw new Error(`Kategorie fehlt im Standardkontenrahmen: ${name}`);
return id;
};
const kassier = await prisma.person.findFirst({
where: {
organisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KASSIER } } },
},
select: { id: true },
});
// --- Buchungen ------------------------------------------------------------
//
// Das Vorjahr vollständig und festgeschrieben, das laufende Jahr bis in den
// Sommer und noch offen. So zeigt die Ansicht beide Zustände: das
// geschlossene Buch und das, in dem noch gearbeitet wird.
type Zeile = {
konto: string;
monat: number;
tag: number;
art: BuchungsArt;
betrag: string;
kategorie: string;
zweck: string;
gegenueber?: string;
beleg?: string;
};
const vorjahrszeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
beleg: "Z-2024-01",
},
{
konto: giro.id,
monat: 2,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "1284.50",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 3,
tag: 12,
art: BuchungsArt.AUSGABE,
betrag: "412.90",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 4,
tag: 8,
art: BuchungsArt.AUSGABE,
betrag: "2340.00",
kategorie: "Atemschutz",
zweck: "Zwei Pressluftatmer, Ersatzbeschaffung",
gegenueber: "Dräger Safety",
beleg: "RE-2024-114",
},
{
konto: giro.id,
monat: 5,
tag: 22,
art: BuchungsArt.AUSGABE,
betrag: "868.00",
kategorie: "Prüfungen",
zweck: "Wiederkehrende Prüfung Drehleiter",
gegenueber: "TÜV Süd",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.EINNAHME,
betrag: "3860.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 6,
tag: 15,
art: BuchungsArt.AUSGABE,
betrag: "1420.60",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: giro.id,
monat: 7,
tag: 2,
art: BuchungsArt.AUSGABE,
betrag: "1150.00",
kategorie: "Schutzkleidung",
zweck: "Vier Einsatzjacken, Ersatz nach Verschleiß",
gegenueber: "Feuerwehrbedarf Barth",
},
{
konto: giro.id,
monat: 9,
tag: 18,
art: BuchungsArt.AUSGABE,
betrag: "624.00",
kategorie: "Ausbildung",
zweck: "Lehrgangsgebühren Maschinistenlehrgang",
gegenueber: "Landkreis Musterkreis",
},
{
konto: giro.id,
monat: 10,
tag: 30,
art: BuchungsArt.AUSGABE,
betrag: "398.20",
kategorie: "Wasser und Strom",
zweck: "Abschlagszahlung Strom Gerätehaus",
gegenueber: "Stadtwerke Musterhausen",
},
{
konto: giro.id,
monat: 11,
tag: 14,
art: BuchungsArt.EINNAHME,
betrag: "780.00",
kategorie: "Spenden",
zweck: "Spenden aus der Haussammlung",
},
{
konto: giro.id,
monat: 12,
tag: 20,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung viertes Quartal",
},
];
const laufendeZeilen: Zeile[] = [
{
konto: giro.id,
monat: 1,
tag: 20,
art: BuchungsArt.EINNAHME,
betrag: "6500.00",
kategorie: "Zuschüsse",
zweck: "Gemeindezuschuss laufender Betrieb",
gegenueber: "Gemeinde Musterhausen",
},
{
konto: giro.id,
monat: 2,
tag: 5,
art: BuchungsArt.AUSGABE,
betrag: "1341.80",
kategorie: "Versicherungen",
zweck: "Jahresprämie Fahrzeug- und Unfallversicherung",
gegenueber: "Bayerische Versicherungskammer",
},
{
konto: giro.id,
monat: 2,
tag: 28,
art: BuchungsArt.AUSGABE,
betrag: "487.40",
kategorie: "Kraftstoff",
zweck: "Diesel LF 20 und MTW, Sammelrechnung Februar",
gegenueber: "Tankstelle Musterhausen",
},
{
konto: giro.id,
monat: 3,
tag: 14,
art: BuchungsArt.AUSGABE,
betrag: "1890.00",
kategorie: "Reparatur",
zweck: "Kupplung MTW, Werkstattrechnung",
gegenueber: "Werkstatt Müller GmbH",
beleg: "RE-4471",
},
{
konto: giro.id,
monat: 4,
tag: 3,
art: BuchungsArt.AUSGABE,
betrag: "212.00",
kategorie: "Verwaltung",
zweck: "Büromaterial und Druckerpatronen",
gegenueber: "Schreibwaren Ludwig",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "4120.00",
kategorie: "Veranstaltungen",
zweck: "Maifest, Getränke- und Speisenverkauf",
},
{
konto: bar.id,
monat: 5,
tag: 1,
art: BuchungsArt.AUSGABE,
betrag: "1588.30",
kategorie: "Verpflegung",
zweck: "Maifest, Einkauf Getränke und Fleisch",
gegenueber: "Getränke Hofmann",
},
{
konto: bar.id,
monat: 5,
tag: 6,
art: BuchungsArt.AUSGABE,
betrag: "2500.00",
kategorie: "Verwaltung",
zweck: "Einzahlung Barkasse auf das Girokonto nach dem Maifest",
},
{
konto: giro.id,
monat: 5,
tag: 6,
art: BuchungsArt.EINNAHME,
betrag: "2500.00",
kategorie: "Veranstaltungen",
zweck: "Einzahlung aus der Barkasse nach dem Maifest",
},
{
konto: giro.id,
monat: 6,
tag: 11,
art: BuchungsArt.AUSGABE,
betrag: "742.00",
kategorie: "Prüfungen",
zweck: "Schlauchprüfung, 40 Stück B und C",
gegenueber: "Schlauchpflege Nordbayern",
},
{
konto: giro.id,
monat: 6,
tag: 25,
art: BuchungsArt.AUSGABE,
betrag: "36.00",
kategorie: "Bankgebühren",
zweck: "Kontoführung zweites Quartal",
},
{
konto: ruecklage.id,
monat: 7,
tag: 1,
art: BuchungsArt.EINNAHME,
betrag: "3000.00",
kategorie: "Zuschüsse",
zweck: "Jährliche Zuführung zur Fahrzeugrücklage",
gegenueber: "Gemeinde Musterhausen",
},
];
async function buchungAnlegen(zeile: Zeile, festgeschrieben: boolean, buchungsjahr: number) {
return prisma.buchung.create({
data: {
organisationId,
kontoId: zeile.konto,
kategorieId: kategorie(zeile.kategorie),
datum: datum(buchungsjahr, zeile.monat, zeile.tag),
art: zeile.art,
betrag: zeile.betrag,
verwendungszweck: zeile.zweck,
lieferant: zeile.gegenueber ?? null,
belegnummer: zeile.beleg ?? null,
erfasstVonId: kassier?.id ?? null,
festgeschriebenAm: festgeschrieben ? datum(vorjahr + 1, 2, 10) : null,
},
});
}
for (const zeile of vorjahrszeilen) {
await buchungAnlegen(zeile, true, vorjahr);
}
for (const zeile of laufendeZeilen) {
await buchungAnlegen(zeile, false, jahr);
}
// --- Beiträge -------------------------------------------------------------
const satzAktive = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Aktive und Passive",
betrag: "25.00",
abteilung: null,
abJahr: vorjahr,
},
});
const satzJugend = await prisma.beitragssatz.create({
data: {
organisationId,
bezeichnung: "Jugendfeuerwehr",
betrag: "10.00",
abteilung: "Jugendfeuerwehr",
abJahr: vorjahr,
},
});
const mitglieder = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "asc" },
select: { id: true, abteilung: true },
});
const beitragsKategorie = kategorie("Mitgliedsbeiträge");
// Die meisten haben bezahlt, zwei stehen offen, einer ist gemahnt, einem
// wurde erlassen. Das ist die Verteilung, die eine Wehr im Herbst wirklich
// vor sich hat — und der Grund, warum es die Rückstandsliste gibt.
for (const [stelle, person] of mitglieder.entries()) {
const jugend = person.abteilung === "Jugendfeuerwehr";
const satz = jugend ? satzJugend : satzAktive;
const betrag = jugend ? "10.00" : "25.00";
let buchungId: string | null = null;
let stand: BeitragStand = BeitragStand.BEZAHLT;
let bezahltAm: Date | null = datum(jahr, 2, 12 + (stelle % 10));
let mahnstufe = 0;
let gemahntAm: Date | null = null;
let erlassGrund: string | null = null;
if (stelle % 7 === 3) {
stand = BeitragStand.OFFEN;
bezahltAm = null;
} else if (stelle % 11 === 5) {
stand = BeitragStand.GEMAHNT;
bezahltAm = null;
mahnstufe = 1;
gemahntAm = datum(jahr, 6, 30);
} else if (stelle % 13 === 9) {
stand = BeitragStand.ERLASSEN;
bezahltAm = null;
erlassGrund = "Längere Erkrankung, Beschluss der Kommandantschaft vom 12.03.";
}
if (stand === BeitragStand.BEZAHLT && bezahltAm) {
const buchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: beitragsKategorie,
datum: bezahltAm,
art: BuchungsArt.EINNAHME,
betrag,
verwendungszweck: `Mitgliedsbeitrag ${jahr}`,
personId: person.id,
erfasstVonId: kassier?.id ?? null,
},
});
buchungId = buchung.id;
}
await prisma.beitrag.create({
data: {
organisationId,
personId: person.id,
jahr,
satzId: satz.id,
betrag,
stand,
buchungId,
bezahltAm,
mahnstufe,
gemahntAm,
erlassGrund,
},
});
}
// --- Rechnungen -----------------------------------------------------------
const kostenersatz = kategorie("Kostenersatz");
const fehlalarmBuchung = await prisma.buchung.create({
data: {
organisationId,
kontoId: giro.id,
kategorieId: kostenersatz,
datum: datum(jahr, 4, 28),
art: BuchungsArt.EINNAHME,
betrag: "612.00",
verwendungszweck: `Zahlungseingang Rechnung ${jahr}-0001`,
lieferant: "Möbelhaus Sonnleitner GmbH",
erfasstVonId: kassier?.id ?? null,
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0001`,
anlass: RechnungsAnlass.FEHLALARM,
stand: RechnungStand.BEZAHLT,
empfaenger: "Möbelhaus Sonnleitner GmbH",
strasse: "Industriestraße 14",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 3, 28),
faelligAm: datum(jahr, 4, 25),
betrag: "612.00",
bemerkung:
"Fehlalarm der Brandmeldeanlage am 26.03., verursacht durch Staub bei Umbauarbeiten. " +
"Kostenersatz nach Art. 28 Abs. 2 Nr. 4 BayFwG.",
buchungId: fehlalarmBuchung.id,
bezahltAm: datum(jahr, 4, 28),
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Einsatz LF 20, angefangene Stunde",
menge: "1",
einheit: "Std.",
einzelpreis: "396.00",
gesamtpreis: "396.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Einsatzkräfte, je angefangene Stunde",
menge: "9",
einheit: "Pers.",
einzelpreis: "24.00",
gesamtpreis: "216.00",
},
],
},
},
});
await prisma.rechnung.create({
data: {
organisationId,
nummer: `${jahr}-0002`,
anlass: RechnungsAnlass.VERMIETUNG,
stand: RechnungStand.VERSENDET,
empfaenger: "Trachtenverein Almrausch Musterhausen e. V.",
empfaengerZusatz: "z. Hd. Frau Vorstand Berger",
strasse: "Dorfplatz 2",
plz: "91315",
ort: "Musterhausen",
datum: datum(jahr, 6, 20),
faelligAm: datum(jahr, 7, 18),
betrag: "180.00",
bemerkung: "Überlassung des Schulungsraums für die Jahresversammlung am 14.06.",
erstelltVonId: kassier?.id ?? null,
positionen: {
create: [
{
organisationId,
reihenfolge: 0,
bezeichnung: "Nutzung Schulungsraum, Tagespauschale",
menge: "1",
einheit: "Tag",
einzelpreis: "150.00",
gesamtpreis: "150.00",
},
{
organisationId,
reihenfolge: 1,
bezeichnung: "Reinigung",
menge: "1",
einheit: "pauschal",
einzelpreis: "30.00",
gesamtpreis: "30.00",
},
],
},
},
});
// --- Kassenprüfung des Vorjahres -----------------------------------------
//
// Zuletzt, und das ist der Punkt: ab hier ist der Zeitraum zu. Jede weitere
// Buchung mit einem Datum aus dem Vorjahr wird von der Datenbank abgelehnt.
const pruefer = await prisma.person.findMany({
where: { organisationId, aktiv: true, austrittsdatum: null },
orderBy: { mitgliedsnummer: "desc" },
take: 2,
select: { id: true },
});
// Die Zahlen sind nicht erfunden, sondern die Summe der Buchungen oben:
// Barkasse 250,00 + 3.860,00 − 1.420,60, Giro 4.800,00 + 7.280,00 − 7.113,60,
// Rücklage unberührt. Ein Seed mit erfundenen Beständen wäre genau der
// Fall, den die Kassenprüfung finden soll.
const vorjahrsposten = [
{ konto: bar.id, buch: "2689.40", gezaehlt: "2686.20" },
{ konto: giro.id, buch: "4966.40", gezaehlt: "4966.40" },
{ konto: ruecklage.id, buch: "12000.00", gezaehlt: "12000.00" },
];
const abschluss = await prisma.kassenabschluss.create({
data: {
organisationId,
vonAm: datum(vorjahr, 1, 1),
bisAm: datum(vorjahr, 12, 31),
bestandLautBuch: "19655.80",
gezaehlterBestand: "19652.60",
differenz: "-3.20",
erlaeuterung:
"In der Barkasse fehlen 3,20 €. Zurückzuführen auf das Maifest: das Wechselgeld " +
"wurde am Abend gezählt, ein Beleg über den Nachkauf von Eis fehlt. Die Prüfer " +
"halten den Betrag für erklärbar und schlagen Entlastung vor.",
prueferId: pruefer[0]?.id ?? null,
zweiterPrueferId: pruefer[1]?.id ?? null,
geprueftAm: datum(vorjahr + 1, 2, 8),
festgeschriebenAm: datum(vorjahr + 1, 2, 10),
posten: {
create: vorjahrsposten.map((posten) => ({
organisationId,
kontoId: posten.konto,
bestandLautBuch: posten.buch,
gezaehlterBestand: posten.gezaehlt,
differenz: (Number(posten.gezaehlt) - Number(posten.buch)).toFixed(2),
})),
},
},
});
log(
`Kasse: 3 Konten, ${STANDARDKATEGORIEN.length} Kategorien, ` +
`${vorjahrszeilen.length + laufendeZeilen.length} Buchungen, ` +
`${mitglieder.length} Beiträge, 2 Rechnungen und die Kassenprüfung ${vorjahr} ` +
`(festgeschrieben — der Zeitraum ist zu, Abschluss ${abschluss.id.slice(0, 8)})`,
);
}
// -----------------------------------------------------------------------------
// Phase 10 — Mandantenfähigkeit
//
// Eine zweite, kleine Wehr und ein Konto, das beide sieht.
//
// Ohne die zweite Wehr lässt sich die ganze Phase nicht ausprobieren: der
// Wehrwähler erscheint erst, wenn es etwas zu wählen gibt, und eine
// Mandantentrennung, die nie zwei Mandanten hatte, ist keine geprüfte.
//
// Die Geschichte ist die übliche in Bayern: der Kommandant von Musterhausen
// ist zugleich Kreisbrandinspektor und betreut die Nachbarwehr. In seiner
// eigenen Wehr führt er alles, in der betreuten sieht er zu — mehr nicht.
//
// Nachbarshausen bleibt bewusst dünn: drei Leute, ein Fahrzeug. Es soll
// zeigen, dass die Trennung hält, und nicht die Beispieldaten verdoppeln.
// -----------------------------------------------------------------------------
/**
* Die Nachbarwehr leitet sich aus der Wehr ab, die gerade angelegt wird.
*
* Fest verdrahtet wäre sie das, was Phase 8 gerade beseitigt hat: der
* Testlauf und die Beispielwehr griffen auf denselben Datensatz zu. So bekommt
* jeder Mandant sein eigenes Paar — und die Trennung gilt auch für das, womit
* sie geprüft wird.
*/
const NACHBAR_SLUG = `${ORGANISATION_SLUG}-nachbar`;
const NACHBAR_NAME = `${ORGANISATION_NAME} — Nachbarwehr`;
const NACHBAR_KURZNAME = `${ORGANISATION_KURZNAME} Nachbar`;
const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${ORGANISATION_SLUG}.de`;
async function kreisebeneAnlegen(
heimatOrganisationId: string,
katalog: Map<string, string>,
): Promise<void> {
/**
* Zwei Teile, zwei Prüfungen — und das ist wichtiger, als es aussieht.
*
* Ein gemeinsamer Ausstieg am Anfang hätte heißen: steht die Nachbarwehr,
* geschieht gar nichts mehr. Genau das ist beim Zurücksetzen der Testwehr
* passiert — die Nachbarwehr blieb stehen, die Mitgliedschaft in der neuen
* Testwehr wurde nie wieder angelegt, und der Wehrwähler zeigte nur noch
* die betreute Wehr. Gefunden hat das ein E2E-Test.
*/
const nachbar =
(await prisma.organisation.findUnique({ where: { slug: NACHBAR_SLUG } })) ??
(await nachbarwehrAnlegen(katalog));
// --- Das Konto, das beide Wehren sieht ------------------------------------
await kreiskontoAnlegen(heimatOrganisationId, nachbar.id);
}
/** Die Nachbarwehr: dünn besetzt, ein Fahrzeug. Sie soll zeigen, dass die
* Trennung hält, und nicht die Beispieldaten verdoppeln. */
async function nachbarwehrAnlegen(katalog: Map<string, string>) {
const nachbar = await prisma.organisation.create({
data: {
slug: NACHBAR_SLUG,
name: NACHBAR_NAME,
kurzname: NACHBAR_KURZNAME,
strasse: "Dorfstraße 3",
plz: "91316",
ort: "Nachbarshausen",
landkreis: "Musterkreis",
funkPraefix: "Florian Nachbarshausen",
},
});
const nachbarRollen = await rollenAnlegen(nachbar.id, katalog);
const besetzung: Array<{
vorname: string;
nachname: string;
nummer: string;
dienstgrad: Dienstgrad;
rolle: RolleSchluessel;
}> = [
{
vorname: "Georg",
nachname: "Sailer",
nummer: "0001",
dienstgrad: Dienstgrad.BRANDMEISTER,
rolle: RolleSchluessel.KOMMANDANT,
},
{
vorname: "Marianne",
nachname: "Kraus",
nummer: "0002",
dienstgrad: Dienstgrad.LOESCHMEISTER,
rolle: RolleSchluessel.GERAETEWART,
},
{
vorname: "Tobias",
nachname: "Ostermeier",
nummer: "0003",
dienstgrad: Dienstgrad.FEUERWEHRMANN,
rolle: RolleSchluessel.MITGLIED,
},
];
for (const eintrag of besetzung) {
const person = await prisma.person.create({
data: {
organisationId: nachbar.id,
vorname: eintrag.vorname,
nachname: eintrag.nachname,
mitgliedsnummer: eintrag.nummer,
dienstgrad: eintrag.dienstgrad,
abteilung: "Aktive",
eintrittsdatum: datum(2012, 4, 1),
},
});
const rolleId = nachbarRollen.get(eintrag.rolle);
if (rolleId) {
await prisma.personRolle.create({
data: { organisationId: nachbar.id, personId: person.id, rolleId },
});
}
}
const standort = await prisma.standort.create({
data: {
organisationId: nachbar.id,
name: "Gerätehaus Nachbarshausen",
art: StandortArt.GERAETEHAUS,
strasse: "Dorfstraße 3",
plz: "91316",
ort: "Nachbarshausen",
},
});
await prisma.fahrzeug.create({
data: {
organisationId: nachbar.id,
funkrufname: "Florian Nachbarshausen 42/1",
bezeichnung: "TSF-W",
art: FahrzeugArt.LOESCHFAHRZEUG,
kennzeichen: "MK-FW 421",
baujahr: 2009,
standortId: standort.id,
},
});
log(`Nachbarwehr angelegt: ${besetzung.length} Personen, 1 Fahrzeug.`);
return nachbar;
}
/** Das Konto der Kreisebene: Mitglied in der Heimatwehr, Aufsicht nebenan. */
async function kreiskontoAnlegen(heimatOrganisationId: string, nachbarId: string) {
const kommandant = await prisma.person.findFirst({
where: {
organisationId: heimatOrganisationId,
rollen: { some: { rolle: { schluessel: RolleSchluessel.KOMMANDANT } } },
},
orderBy: { mitgliedsnummer: "asc" },
});
if (!kommandant) {
log("Kein Kommandant gefunden — die Kreisebene wird übersprungen.");
return;
}
const email = KBI_EMAIL;
const passwort = process.env.SEED_KBI_PASSWORT ?? "Feuerwehr2026";
const konto = await prisma.benutzer.upsert({
where: { email },
update: {},
create: {
email,
passwortHash: await bcrypt.hash(passwort, RUNDEN),
passwortGeaendertAm: new Date(),
},
});
// In der eigenen Wehr: Mitglied mit allem, was seine Rollen hergeben.
await prisma.mitgliedschaft.upsert({
where: {
benutzerId_organisationId: {
benutzerId: konto.id,
organisationId: heimatOrganisationId,
},
},
update: {},
create: {
organisationId: heimatOrganisationId,
benutzerId: konto.id,
personId: kommandant.id,
art: MitgliedschaftArt.MITGLIED,
},
});
// In der Nachbarwehr: Aufsicht. Ohne Akte — er gehört ihr nicht an und
// steht deshalb auch in keiner ihrer Mitgliederlisten.
await prisma.mitgliedschaft.upsert({
where: {
benutzerId_organisationId: { benutzerId: konto.id, organisationId: nachbarId },
},
update: {},
create: {
organisationId: nachbarId,
benutzerId: konto.id,
art: MitgliedschaftArt.AUFSICHT,
},
});
log(
`Kreisebene: ${email} — Mitglied in ${ORGANISATION_KURZNAME}, ` +
`Aufsicht in ${NACHBAR_KURZNAME}.`,
);
}
// -----------------------------------------------------------------------------
// Ablauf
// -----------------------------------------------------------------------------
@@ -3293,6 +4182,12 @@ async function main(): Promise<void> {
// Phase 8 — Lokale KI
await wikiAnlegen(organisation.id);
// Phase 9 — Kassenverwaltung
await kassendatenAnlegen(organisation.id);
// Phase 10 — Mandantenfähigkeit: eine zweite Wehr und die Kreisebene
await kreisebeneAnlegen(organisation.id, katalog);
log("Fertig.");
log("");
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
+52
View File
@@ -256,6 +256,46 @@ const AUFNAHMEN: Aufnahme[] = [
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
angemeldet: true,
},
// Phase 9 — Kassenverwaltung
{
name: "45-kasse",
pfad: "/kasse",
beschreibung: "Kassenübersicht mit den Beständen aller Konten",
angemeldet: true,
},
{
name: "46-kassenbuch",
pfad: "/kasse/buchungen",
beschreibung: "Das Kassenbuch mit Summen über alle Treffer",
angemeldet: true,
},
{
name: "47-beitraege",
pfad: "/kasse/beitraege",
beschreibung: "Mitgliedsbeiträge mit Rückstandsliste",
angemeldet: true,
},
{
name: "48-rechnungen",
pfad: "/kasse/rechnungen",
beschreibung: "Rechnungen an Dritte mit Nummernkreis",
angemeldet: true,
},
{
name: "49-kassenabschluss",
pfad: "/kasse/abschluss",
beschreibung: "Kassenprüfung mit Differenz und Erläuterung",
angemeldet: true,
},
// Phase 10 — Mandantenfähigkeit
{
name: "50-wehren",
pfad: "/wehren",
beschreibung: "Wehren dieser Installation, Konten und ihre Zuordnungen",
angemeldet: true,
},
];
/**
@@ -340,6 +380,18 @@ async function fertigGeladen(seite: Page): Promise<void> {
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
.catch(() => undefined);
/**
* Erst warten, dass React überhaupt gelaufen ist.
*
* Ohne diese Pause war die Bedingung unten in einem Fall erfüllt, in dem sie
* nichts bedeutet: „keine Ladeanzeige“ trifft auch zu, *bevor* sie erscheint.
* Bei einer Route, die der Entwicklungsserver erst übersetzen muss, kam der
* Auslöser genau in diesem Moment — und das Bild zeigte eine leere Seite mit
* Überschriften.
*/
await seite.waitForTimeout(1_500);
await seite.waitForLoadState("networkidle");
try {
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
timeout: 15_000,
+94
View File
@@ -0,0 +1,94 @@
/**
* Die Sperren der Anwendungsrolle — nach einer Wiederherstellung.
*
* Hintergrund, und er ist unerwartet:
*
* Beim ersten Start richtet `docker/postgres/10-init-app-role.sh` eine
* Vorgabeprivilegierung ein — jede künftig angelegte Tabelle bekommt
* automatisch alle Rechte für `florian_app`. Das ist im Betrieb genau richtig:
* eine neue Migration soll nicht daran scheitern, dass jemand vergessen hat,
* Rechte zu vergeben.
*
* `pg_restore` legt die Tabellen aber ebenfalls neu an. Die Vorgabe greift
* also auch dort — und die `GRANT`-Anweisungen aus dem Dump können nur
* hinzufügen, nie wegnehmen. Nach einer Wiederherstellung stünde die
* Anwendungsrolle damit wieder mit allen Rechten da: sie dürfte das Audit-Log
* ändern, Wiki-Fassungen löschen, Kiosk-Vorgänge umschreiben.
*
* Nichts an der Anwendung würde darauf hinweisen. Die Unveränderlichkeit, auf
* der jeder Nachweis gegenüber der Unfallkasse beruht, wäre stillschweigend
* weg — bis jemand danach fragt.
*
* Gefunden haben das die Datenbanktests nach der ersten Rückspielprobe.
* Deshalb gibt es diese Datei: **eine** Liste, aus der sowohl das
* Wiederherstellen als auch das Prüfen der Sperren entsteht. Zwei Listen
* wären zwei Wahrheiten.
*/
/** Tabelle → Rechte, die `florian_app` NICHT haben darf, mit Begründung. */
export const SPERREN: Array<{ tabelle: string; rechte: string[]; grund: string }> = [
{
tabelle: "berechtigung",
rechte: ["INSERT", "UPDATE", "DELETE"],
grund: "systemweiter Rechtekatalog — nur lesbar",
},
{
tabelle: "audit_log",
rechte: ["UPDATE", "DELETE"],
grund: "append-only; das Löschen geht nur im Aufbewahrungslauf",
},
{
tabelle: "kiosk_vorgang",
rechte: ["UPDATE", "DELETE"],
grund: "übermittelte Terminalvorgänge sind unveränderlich",
},
{
tabelle: "druckmessung",
rechte: ["UPDATE", "DELETE"],
grund: "Atemschutzüberwachung — Druckwerte werden nie korrigiert",
},
{
tabelle: "atemschutz_ereignis",
rechte: ["UPDATE", "DELETE"],
grund: "wie oben: der Verlauf einer Überwachung bleibt, wie er war",
},
{
tabelle: "benachrichtigung",
rechte: ["DELETE"],
grund: "was zugestellt wurde, bleibt nachweisbar",
},
{
tabelle: "ki_vorgang",
rechte: ["DELETE"],
grund: "das KI-Protokoll belegt, was die Maschine getan hat",
},
{
tabelle: "wikiversion",
rechte: ["UPDATE", "DELETE"],
grund: "jede Fassung einer Wiki-Seite bleibt erhalten",
},
{
tabelle: "kassenabschluss",
rechte: ["DELETE"],
grund: "ein festgeschriebener Abschluss lässt sich nicht wegräumen",
},
{
tabelle: "benutzer",
rechte: ["INSERT", "DELETE"],
grund: "Konten entstehen und vergehen über den Systemzugang",
},
];
/** Die SQL-Anweisungen, die die Sperren wiederherstellen. */
export function sperrenSql(): string[] {
return SPERREN.map(
({ tabelle, rechte }) => `REVOKE ${rechte.join(", ")} ON "${tabelle}" FROM florian_app`,
);
}
/** Alle Kombinationen, die nach der Wiederherstellung fehlen müssen. */
export function verboteneRechte(): Array<{ tabelle: string; recht: string }> {
return SPERREN.flatMap(({ tabelle, rechte }) =>
rechte.map((recht) => ({ tabelle, recht })),
);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Die Verbindungsangaben für `pg_dump` und `pg_restore`.
*
* Eigene Datei, weil beide Skripte sie brauchen — und weil ein falsch
* zerlegter Zugang der unangenehmste Fehler in einem Sicherungsskript wäre:
* es liefe durch, schriebe eine Datei, und die enthielte die falsche
* Datenbank. Auffallen würde es beim Zurückspielen, also genau dann, wenn man
* die Sicherung braucht.
*/
export interface Zugang {
benutzer: string;
passwort: string;
datenbank: string;
}
/**
* Zerlegt eine Verbindungszeichenkette in ihre Teile.
*
* Die URL lässt sich nicht als Ganzes an `pg_dump` durchreichen: Prisma hängt
* eigene Parameter an (`?schema=public`), die es mit "invalid URI query
* parameter" ablehnt. Und der Port darin gilt vom Rechner aus — im Container,
* wo die Werkzeuge laufen, liegt Postgres auf 5432.
*/
export function zugangAus(roh: string | undefined): Zugang {
if (!roh) {
throw new Error("DIRECT_DATABASE_URL fehlt.");
}
let url: URL;
try {
url = new URL(roh);
} catch {
throw new Error(`Die Verbindungszeichenkette ist keine gültige URL: ${roh}`);
}
if (!url.protocol.startsWith("postgres")) {
throw new Error(`Erwartet wird eine postgresql://-Adresse, bekommen: ${url.protocol}//`);
}
const datenbank = url.pathname.replace(/^\//, "");
if (!datenbank) {
throw new Error("In der Verbindungszeichenkette fehlt der Datenbankname.");
}
return {
benutzer: decodeURIComponent(url.username),
passwort: decodeURIComponent(url.password),
datenbank,
};
}
+188
View File
@@ -0,0 +1,188 @@
/**
* Sicherung: Datenbank und Dateien in ein Verzeichnis.
*
* npm run sicherung
* npm run sicherung -- --ziel D:\backups
*
* Eine Feuerwehrverwaltung ohne geprüfte Sicherung ist ein Aktenschrank ohne
* Schloss — bis zum ersten Festplattenfehler sieht man den Unterschied nicht.
* Deshalb gehört zu diesem Skript zwingend `npm run wiederherstellen`: eine
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung.
*
* Gesichert wird beides, und zwar zusammen:
*
* * **Die Datenbank** über `pg_dump` im Postgres-Container. Im eigenen
* Format (`--format=custom`), weil sich daraus einzelne Tabellen
* zurückholen lassen und es kleiner ist.
* * **Die Dateien** aus MinIO — Prüfprotokolle, Einsatzberichte, Fotos,
* Rechnungen. Ohne sie wäre die Datenbank voller Verweise ins Leere.
*
* Beides muss aus demselben Moment stammen. Eine Datenbank von heute mit
* Dateien von gestern ergibt Datensätze, die auf nichts zeigen.
*/
import "dotenv/config";
import { createHash } from "node:crypto";
import { spawnSync } from "node:child_process";
import { mkdirSync, writeFileSync, createWriteStream } from "node:fs";
import { join, dirname } from "node:path";
import { pipeline } from "node:stream/promises";
import { Client as MinioClient } from "minio";
import { zugangAus } from "./sicherung-zugang";
const ZIEL_VORGABE = "sicherungen";
const CONTAINER = process.env.SICHERUNG_POSTGRES_CONTAINER ?? "florian-postgres";
function log(nachricht: string): void {
console.info(`[sicherung] ${nachricht}`);
}
/** Ein Zeitstempel, der sich als Verzeichnisname eignet. */
function stempel(): string {
return new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
}
function argument(name: string): string | undefined {
const stelle = process.argv.indexOf(`--${name}`);
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
}
/**
* Die Datenbank sichern.
*
* Über `docker compose exec` statt über einen lokalen `pg_dump`: die Version
* des Clients muss zur Serverversion passen, und im Container ist sie das
* garantiert. Auf einem Rechner ohne passendes `pg_dump` schlüge es sonst mit
* einer Meldung fehl, die niemand mit der Ursache in Verbindung bringt.
*/
function datenbankSichern(ziel: string): { datei: string; bytes: number } {
const { benutzer, passwort, datenbank } = zugangAus(
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
);
const datei = join(ziel, "datenbank.dump");
log(`Datenbank aus Container "${CONTAINER}" ...`);
const lauf = spawnSync(
"docker",
[
"compose",
"exec",
"-T",
"postgres",
"pg_dump",
"--format=custom",
// Der Eigentuemer wird nicht mitgesichert (er heisst auf jedem Server
// anders), die RECHTE dagegen schon. Ohne sie waeren nach dem
// Zurueckspielen alle REVOKEs der Migrationen weg: die Anwendungsrolle
// duerfte das Audit-Log aendern, Wiki-Fassungen loeschen,
// Kiosk-Vorgaenge umschreiben. Nichts wuerde darauf hinweisen.
"--no-owner",
"--username",
benutzer,
"--dbname",
datenbank,
],
{
encoding: "buffer",
maxBuffer: 2_000_000_000,
shell: true,
env: { ...process.env, PGPASSWORD: passwort },
},
);
if (lauf.status !== 0) {
const fehler = lauf.stderr?.toString() ?? "unbekannt";
throw new Error(`pg_dump ist fehlgeschlagen:\n${fehler}`);
}
writeFileSync(datei, lauf.stdout);
return { datei: "datenbank.dump", bytes: lauf.stdout.byteLength };
}
/** Alle Objekte aus MinIO holen. */
async function dateienSichern(ziel: string): Promise<{ anzahl: number; bytes: number }> {
const bucket = process.env.S3_BUCKET ?? "florian";
const client = new MinioClient({
endPoint: process.env.S3_ENDPOINT ?? "localhost",
port: Number(process.env.S3_PORT ?? 9000),
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
accessKey: process.env.S3_ACCESS_KEY ?? "",
secretKey: process.env.S3_SECRET_KEY ?? "",
});
if (!(await client.bucketExists(bucket))) {
log(`Bucket "${bucket}" gibt es nicht — keine Dateien zu sichern.`);
return { anzahl: 0, bytes: 0 };
}
const ordner = join(ziel, "dateien");
mkdirSync(ordner, { recursive: true });
let anzahl = 0;
let bytes = 0;
const strom = client.listObjectsV2(bucket, "", true);
for await (const objekt of strom) {
if (!objekt.name) continue;
const pfad = join(ordner, objekt.name);
mkdirSync(dirname(pfad), { recursive: true });
const quelle = await client.getObject(bucket, objekt.name);
await pipeline(quelle, createWriteStream(pfad));
anzahl += 1;
bytes += objekt.size ?? 0;
if (anzahl % 50 === 0) log(` ${anzahl} Dateien ...`);
}
return { anzahl, bytes };
}
async function main(): Promise<void> {
const wurzel = argument("ziel") ?? ZIEL_VORGABE;
const ziel = join(wurzel, stempel());
mkdirSync(ziel, { recursive: true });
log(`Ziel: ${ziel}`);
const datenbank = datenbankSichern(ziel);
log(`Datenbank: ${(datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
const dateien = await dateienSichern(ziel);
log(`Dateien: ${dateien.anzahl} Stück, ${(dateien.bytes / 1024 / 1024).toFixed(1)} MB`);
/**
* Das Manifest.
*
* Es hält fest, woraus die Sicherung stammt und wie groß sie ist. Beim
* Zurückspielen wird es gelesen und angezeigt — damit niemand versehentlich
* eine Sicherung von vor drei Monaten einspielt, weil das Verzeichnis
* ähnlich hieß.
*/
const manifest = {
erstelltAm: new Date().toISOString(),
version: process.env.npm_package_version ?? "unbekannt",
datenbank,
dateien,
pruefsumme: createHash("sha256")
.update(`${datenbank.bytes}:${dateien.anzahl}:${dateien.bytes}`)
.digest("hex"),
};
writeFileSync(join(ziel, "manifest.json"), JSON.stringify(manifest, null, 2));
log("");
log("Fertig. Zum Prüfen der Sicherung:");
log(` npm run wiederherstellen -- --quelle ${ziel} --wirklich`);
log("");
log("Eine Sicherung, die nie zurueckgespielt wurde, ist eine Vermutung.");
}
main().catch((fehler) => {
console.error("[sicherung] Abbruch:", fehler);
process.exit(1);
});
+16 -3
View File
@@ -53,8 +53,20 @@ async function main(): Promise<void> {
);
}
const vorhanden = await prisma.organisation.findUnique({
where: { slug: SLUG },
/**
* Auch die Nachbarwehr aus Phase 10 und das Konto der Kreisebene.
*
* Beide gehören zur Testwehr, liegen aber nicht in ihr: die Nachbarwehr ist
* eine eigene Organisation, das Konto gehört gar keiner. Blieben sie beim
* Zurücksetzen stehen, sähe der Seed die Nachbarwehr, hielte alles für
* erledigt und legte die Mitgliedschaft in der neuen Testwehr nie wieder an
* — der Wehrwähler zeigte danach nur noch die betreute Wehr.
*/
const NACHBAR_SLUG = `${SLUG}-nachbar`;
const KBI_EMAIL = process.env.SEED_KBI_EMAIL ?? `kbi@${SLUG}.de`;
const vorhanden = await prisma.organisation.findFirst({
where: { slug: { in: [SLUG, NACHBAR_SLUG] } },
select: { id: true, name: true },
});
@@ -79,7 +91,8 @@ async function main(): Promise<void> {
await tx.$executeRawUnsafe(`ALTER TABLE "${tabelle}" DISABLE TRIGGER USER`);
}
await tx.organisation.delete({ where: { slug: SLUG } });
await tx.organisation.deleteMany({ where: { slug: { in: [SLUG, NACHBAR_SLUG] } } });
await tx.benutzer.deleteMany({ where: { email: KBI_EMAIL } });
for (const tabelle of [
"druckmessung",
+270
View File
@@ -0,0 +1,270 @@
/**
* Wiederherstellung aus einer Sicherung.
*
* npm run wiederherstellen -- --quelle sicherungen/2026-09-05T14-00-00
* npm run wiederherstellen -- --quelle ... --wirklich
*
* Ohne `--wirklich` wird nur angezeigt, was geschehen würde. Das ist keine
* Bequemlichkeit: dieses Skript **überschreibt den gesamten Bestand**, und es
* gibt keinen Weg zurück außer einer weiteren Sicherung.
*
* Der eigentliche Zweck ist nicht der Notfall, sondern die Probe. Eine
* Sicherung, die nie zurückgespielt wurde, ist eine Vermutung — der Ordner
* kann leer sein, der Dump abgeschnitten, das Passwort falsch. Man erfährt es
* an dem Tag, an dem man es nicht erfahren will. Deshalb gehört dieser Lauf
* einmal durchgeführt und im Sicherungskonzept vermerkt.
*/
import "dotenv/config";
import { spawnSync } from "node:child_process";
import { existsSync, readFileSync, readdirSync, statSync, createReadStream } from "node:fs";
import { join, relative, sep } from "node:path";
import { Client as MinioClient } from "minio";
import { zugangAus } from "./sicherung-zugang";
import { SPERREN, sperrenSql, verboteneRechte } from "./sicherung-sperren";
function log(nachricht: string): void {
console.info(`[wiederherstellen] ${nachricht}`);
}
function argument(name: string): string | undefined {
const stelle = process.argv.indexOf(`--${name}`);
return stelle >= 0 ? process.argv[stelle + 1] : undefined;
}
const wirklich = process.argv.includes("--wirklich");
/** Alle Dateien unterhalb eines Verzeichnisses, rekursiv. */
function alleDateien(wurzel: string): string[] {
if (!existsSync(wurzel)) return [];
const gefunden: string[] = [];
for (const eintrag of readdirSync(wurzel)) {
const pfad = join(wurzel, eintrag);
if (statSync(pfad).isDirectory()) gefunden.push(...alleDateien(pfad));
else gefunden.push(pfad);
}
return gefunden;
}
function datenbankZurueck(quelle: string): void {
const { benutzer, passwort, datenbank } = zugangAus(
process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL,
);
const dump = join(quelle, "datenbank.dump");
if (!existsSync(dump)) throw new Error(`In ${quelle} liegt keine datenbank.dump.`);
log("Spiele die Datenbank zurück ...");
/**
* `--clean --if-exists` räumt vorher auf.
*
* Ohne das Aufräumen schlüge jede `CREATE TABLE` fehl, und übrig bliebe ein
* Mischbestand aus alten und neuen Zeilen — das Schlimmste, was bei einer
* Wiederherstellung herauskommen kann, weil es auf den ersten Blick
* funktioniert.
*/
const lauf = spawnSync(
"docker",
[
"compose",
"exec",
"-T",
"postgres",
"pg_restore",
"--clean",
"--if-exists",
"--no-owner",
"--username",
benutzer,
"--dbname",
datenbank,
],
{
input: readFileSync(dump),
encoding: "buffer",
maxBuffer: 2_000_000_000,
shell: true,
env: { ...process.env, PGPASSWORD: passwort },
},
);
// pg_restore meldet auch dann einen Fehlercode, wenn nur einzelne DROP-
// Anweisungen ins Leere liefen. Das ist bei `--if-exists` normal, deshalb
// wird die Ausgabe angesehen statt nur der Rückgabewert.
const ausgabe = lauf.stderr?.toString() ?? "";
const echteFehler = ausgabe
.split("\n")
.filter((zeile) => zeile.includes("error:") && !zeile.includes("does not exist"));
if (echteFehler.length > 0) {
throw new Error(`pg_restore ist fehlgeschlagen:\n${echteFehler.join("\n")}`);
}
log("Datenbank zurückgespielt.");
}
async function dateienZurueck(quelle: string): Promise<number> {
const bucket = process.env.S3_BUCKET ?? "florian";
const ordner = join(quelle, "dateien");
const client = new MinioClient({
endPoint: process.env.S3_ENDPOINT ?? "localhost",
port: Number(process.env.S3_PORT ?? 9000),
useSSL: (process.env.S3_USE_SSL ?? "false") === "true",
accessKey: process.env.S3_ACCESS_KEY ?? "",
secretKey: process.env.S3_SECRET_KEY ?? "",
});
if (!(await client.bucketExists(bucket))) {
await client.makeBucket(bucket);
log(`Bucket "${bucket}" angelegt.`);
}
const dateien = alleDateien(ordner);
let hochgeladen = 0;
for (const pfad of dateien) {
// Der Objektschlüssel ist der Pfad unterhalb von "dateien/" — mit
// Schrägstrichen, auch unter Windows.
const schluessel = relative(ordner, pfad).split(sep).join("/");
await client.putObject(bucket, schluessel, createReadStream(pfad), statSync(pfad).size);
hochgeladen += 1;
if (hochgeladen % 50 === 0) log(` ${hochgeladen} Dateien ...`);
}
return hochgeladen;
}
/**
* Die Sperren wieder setzen — und danach nachsehen, ob es gewirkt hat.
*
* Warum das nötig ist, steht in `sicherung-sperren.ts`: die
* Vorgabeprivilegierung des Init-Skripts gibt jeder von `pg_restore` neu
* angelegten Tabelle wieder alle Rechte, und die `GRANT`-Zeilen aus dem Dump
* können nur hinzufügen, nie wegnehmen.
*
* Erst setzen, dann prüfen — und nicht nur prüfen. Ein Skript, das ein
* bekanntes Problem meldet, statt es zu beheben, verlagert die Arbeit auf den
* ungünstigsten Zeitpunkt: den Notfall, in dem jemand gerade eine Sicherung
* zurückspielt.
*/
async function sperrenSetzen(): Promise<void> {
const { PrismaClient } = await import("@prisma/client");
const prisma = new PrismaClient({
datasources: { db: { url: process.env.DIRECT_DATABASE_URL ?? "" } },
});
try {
const rolleDa = await prisma.$queryRawUnsafe<Array<{ da: boolean }>>(
"SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'florian_app') AS da",
);
if (!rolleDa[0]?.da) {
log("Die Rolle florian_app gibt es nicht — Sperren übersprungen.");
return;
}
for (const anweisung of sperrenSql()) {
await prisma.$executeRawUnsafe(anweisung);
}
log(`Sperren gesetzt: ${SPERREN.length} Tabellen.`);
// Gegenprobe. Sie fragt die Datenbank, nicht das eigene Vorhaben ab.
const zeilen = await prisma.$queryRawUnsafe<
Array<{ table_name: string; privilege_type: string }>
>(
`SELECT table_name, privilege_type
FROM information_schema.role_table_grants
WHERE grantee = 'florian_app'`,
);
const vorhanden = new Set(zeilen.map((z) => `${z.table_name}:${z.privilege_type}`));
const befunde = verboteneRechte()
.filter(({ tabelle, recht }) => vorhanden.has(`${tabelle}:${recht}`))
.map(({ tabelle, recht }) => ` ${tabelle}: ${recht}`);
if (befunde.length > 0) {
log("");
log("ACHTUNG: Die Anwendungsrolle hat Rechte, die sie nicht haben darf:");
befunde.forEach((zeile) => log(zeile));
log("Die Unveraenderlichkeit von Protokollen ist damit aufgehoben.");
process.exitCode = 1;
return;
}
log("Gegenprobe: keine Tabelle hat mehr Rechte als vorgesehen.");
} finally {
await prisma.$disconnect();
}
}
async function main(): Promise<void> {
const quelle = argument("quelle");
if (!quelle) {
console.error("[wiederherstellen] --quelle <Verzeichnis> fehlt.");
process.exit(1);
}
const manifestPfad = join(quelle, "manifest.json");
if (!existsSync(manifestPfad)) {
console.error(
`[wiederherstellen] In ${quelle} liegt keine manifest.json. ` +
"Ist das wirklich ein Sicherungsverzeichnis?",
);
process.exit(1);
}
const manifest = JSON.parse(readFileSync(manifestPfad, "utf-8")) as {
erstelltAm: string;
version: string;
datenbank: { bytes: number };
dateien: { anzahl: number; bytes: number };
};
const alter = Math.round(
(Date.now() - new Date(manifest.erstelltAm).getTime()) / 1000 / 60 / 60 / 24,
);
log("");
log(`Sicherung vom ${manifest.erstelltAm.slice(0, 16).replace("T", " ")} (${alter} Tage alt)`);
log(`Version: ${manifest.version}`);
log(`Datenbank: ${(manifest.datenbank.bytes / 1024 / 1024).toFixed(1)} MB`);
log(`Dateien: ${manifest.dateien.anzahl} Stück`);
log("");
if (!wirklich) {
log("PROBELAUF — es wird nichts geändert.");
log("");
log("Zum wirklichen Zurückspielen dieselbe Zeile mit --wirklich am Ende.");
log("Achtung: dabei wird der gesamte vorhandene Bestand überschrieben.");
return;
}
if (alter > 30) {
log(`WARNUNG: Diese Sicherung ist ${alter} Tage alt.`);
}
log("Spiele zurück — der vorhandene Bestand wird überschrieben.");
datenbankZurueck(quelle);
const dateien = await dateienZurueck(quelle);
log(`Dateien: ${dateien} zurückgespielt.`);
await sperrenSetzen();
log("");
log("Fertig. Bitte jetzt von Hand prüfen:");
log(" 1. Anmelden — kommt jemand hinein?");
log(" 2. Ein Prüfprotokoll öffnen — ist das PDF da?");
log(" 3. Die Mitgliederzahl mit der Erwartung vergleichen.");
log("");
log("Erst danach gilt die Sicherung als geprüft.");
}
main().catch((fehler) => {
console.error("[wiederherstellen] Abbruch:", fehler);
process.exit(1);
});
+5
View File
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
if (!sitzung.personId) redirect("/");
const marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
const bild = await qrTextAlsDataUri(marke, 420);
+11 -2
View File
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
// Beides sind Selbstbedienungsseiten: Anmeldemarke und Dienstausweis gehören
// einem Mitglied. Die Kreisaufsicht hat in dieser Wehr keines — und einen
// Ausweis für eine Wehr, der man nicht angehört, gibt es nicht.
if (!sitzung.personId) redirect("/");
// In eine eigene Konstante, damit die Einengung auch im Rueckruf gilt:
// TypeScript verfolgt sie ueber eine Eigenschaft hinweg nicht.
const eigeneAkteId = sitzung.personId;
const daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
const person = await db.person.findUnique({
where: { id: sitzung.personId },
where: { id: eigeneAkteId },
select: {
vorname: true,
nachname: true,
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
return person;
});
const code = ausweisErzeugen(sitzung.personId, sitzung.organisationId);
const code = ausweisErzeugen(eigeneAkteId, sitzung.organisationId);
const adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
const bild = await qrTextAlsDataUri(adresse, 420);
+179 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { Camera, Check, Trash2, Wallet } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
@@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
@@ -33,9 +48,11 @@ import { alsDatum, alsEuro } from "@/lib/utils";
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) {
const werkzeuge = trpc.useUtils();
const [buchen, setBuchen] = React.useState<Beleg | null>(null);
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
@@ -141,7 +158,8 @@ export function BelegListe() {
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
<th className="py-2 pr-4">Gegengezeichnet</th>
<th className="py-2">Kasse</th>
</tr>
</thead>
<tbody>
@@ -158,11 +176,23 @@ export function BelegListe() {
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
<td className="py-2 pr-4">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
<td className="py-2">
{beleg.buchungId ? (
<Badge variant="einsatzbereit">gebucht</Badge>
) : beleg.stand === KiStand.UEBERNOMMEN && darfBuchen ? (
<Button size="sm" variant="outline" onClick={() => setBuchen(beleg)}>
<Wallet aria-hidden />
Buchen
</Button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
@@ -170,6 +200,19 @@ export function BelegListe() {
</div>
</section>
)}
<BelegBuchenDialog
beleg={buchen}
schliessen={() => setBuchen(null)}
fehler={setMeldung}
fertig={(betrag) => {
setBuchen(null);
setHinweis(
`Beleg über ${betrag} gebucht. Die Buchung steht als Entwurf im Kassenbuch.`,
);
void werkzeuge.assistent.invalidate();
}}
/>
</div>
);
}
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
</Card>
);
}
/**
* Aus einem gegengezeichneten Beleg eine Buchung machen.
*
* Konto und Kategorie fehlen dem Beleg — das kann keine Maschine entscheiden,
* und der Kassier soll es auch nicht raten müssen. Betrag, Datum und Händler
* stehen bereits fest; sie stammen aus dem gegengezeichneten Beleg und werden
* hier nicht noch einmal zur Bearbeitung gestellt.
*
* Das Foto hängt danach an der Buchung. Genau das verlangt eine
* Kassenprüfung: zu jeder Ausgabe ein Beleg, und zwar an der Buchung und
* nicht in einem Ordner daneben.
*/
function BelegBuchenDialog({
beleg,
schliessen,
fertig,
fehler,
}: {
beleg: Beleg | null;
schliessen: () => void;
fertig: (betrag: string) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery(undefined, { enabled: beleg !== null });
const kategorien = trpc.buchungskategorie.liste.useQuery(
{ art: "AUSGABE" },
{ enabled: beleg !== null },
);
const vorschlag = trpc.buchung.vorschlag.useQuery(
{
verwendungszweck: `${beleg?.haendler ?? ""} ${beleg?.kategorie ?? ""}`.trim(),
art: "AUSGABE",
},
{ enabled: beleg !== null && Boolean(beleg.haendler ?? beleg.kategorie) },
);
const buchen = trpc.buchung.belegBuchen.useMutation({
onSuccess: (buchung) => fertig(alsEuro(Number(buchung.betrag))),
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Derselbe Vorschlag wie in der Buchungsmaske — sonst landet derselbe
// Händler einmal unter Kraftstoff und einmal unter Fahrzeuge, und die
// Jahresauswertung ist dahin.
React.useEffect(() => {
if (!kategorieId && vorschlag.data) setKategorieId(vorschlag.data.kategorieId);
}, [kategorieId, vorschlag.data]);
React.useEffect(() => {
if (!beleg) setKategorieId("");
}, [beleg]);
return (
<Dialog open={beleg !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beleg buchen</DialogTitle>
<DialogDescription>
{beleg?.haendler ?? "Beleg"}
{beleg?.betrag !== null &&
beleg?.betrag !== undefined &&
` · ${alsEuro(Number(beleg.betrag))}`}
{beleg?.belegdatum && ` · ${alsDatum(beleg.belegdatum)}`}. Es entsteht eine Ausgabe im
Kassenbuch, mit dem Foto als Beleg. Sie ist zunächst ein Entwurf.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beleg-konto">Von welchem Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="beleg-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="beleg-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="beleg-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
{vorschlag.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Vorgeschlagen: {vorschlag.data.name} — {vorschlag.data.grund}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => beleg && buchen.mutate({ belegId: beleg.id, kontoId, kategorieId })}
disabled={!kontoId || !kategorieId || buchen.isPending}
>
{buchen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5 -5
View File
@@ -8,9 +8,9 @@ export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
* Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9
* buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst,
* führt deshalb noch lange nicht die Kasse.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
@@ -22,11 +22,11 @@ export default async function BelegeSeite() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
Quittung fotografieren, Angaben prüfen, gegenzeichnen — und ins Kassenbuch buchen.
</p>
</div>
<BelegListe />
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+780
View File
@@ -0,0 +1,780 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Download, FileText, Lock, Plus } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
const MONATE = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
/**
* Abschluss und Auswertung.
*
* Zwei Dinge, die zusammengehören: der Kassenprüfer will die Jahreszahlen
* sehen, bevor er unterschreibt, und die Jahreshauptversammlung will beides.
*/
export function Abschlussansicht({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const [reiter, setReiter] = React.useState<"pruefung" | "auswertung">("pruefung");
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
<div className="flex gap-2 border-b border-[var(--border)]">
<Reiter aktiv={reiter === "pruefung"} beim={() => setReiter("pruefung")}>
Kassenprüfung
</Reiter>
<Reiter aktiv={reiter === "auswertung"} beim={() => setReiter("auswertung")}>
Auswertung
</Reiter>
</div>
{reiter === "pruefung" ? (
<Pruefungen darfAbschliessen={darfAbschliessen} />
) : (
<Auswertung />
)}
</div>
);
}
function Reiter({
aktiv,
beim,
children,
}: {
aktiv: boolean;
beim: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={beim}
className={`-mb-px border-b-2 px-3 py-2 text-sm ${
aktiv
? "border-[var(--primary)] font-medium"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{children}
</button>
);
}
/** Die Liste der Prüfungen — und der Weg zu einer neuen. */
function Pruefungen({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const werkzeuge = trpc.useUtils();
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const abschluesse = trpc.kassenabschluss.liste.useQuery();
const festschreiben = trpc.kassenabschluss.festschreiben.useMutation({
onSuccess: () => {
setHinweis(
"Festgeschrieben. Der Zeitraum ist zu, und der Prüfbericht entsteht im Hintergrund.",
);
void werkzeuge.kassenabschluss.invalidate();
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="abschlussfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{darfAbschliessen && (
<div className="flex justify-end">
<Button onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Kassenprüfung anlegen
</Button>
</div>
)}
{!abschluesse.isLoading && (abschluesse.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Kassenprüfung"
hinweis="Ein Abschluss hält fest, was gezählt wurde und was das Buch sagt — und schließt den Zeitraum danach."
/>
)}
<div className="space-y-3">
{abschluesse.data?.map((abschluss) => {
const differenz = Number(abschluss.differenz);
return (
<Card key={abschluss.id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">
{alsDatum(abschluss.vonAm)} bis {alsDatum(abschluss.bisAm)}
</CardTitle>
<CardDescription>
{abschluss.pruefer
? `Geprüft von ${abschluss.pruefer.vorname} ${abschluss.pruefer.nachname}`
: "Noch kein Prüfer eingetragen"}
{abschluss.zweiterPruefer &&
` und ${abschluss.zweiterPruefer.vorname} ${abschluss.zweiterPruefer.nachname}`}
{abschluss.geprueftAm && ` am ${alsDatum(abschluss.geprueftAm)}`}
{` · ${abschluss._count.posten} Konten`}
</CardDescription>
</div>
{abschluss.festgeschriebenAm ? (
<Badge variant="einsatzbereit">
<Lock className="size-3" aria-hidden />
Festgeschrieben
</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 text-sm sm:grid-cols-3">
<Wert titel="Laut Buch" wert={abschluss.bestandLautBuch} />
<Wert titel="Gezählt" wert={abschluss.gezaehlterBestand} />
<Wert
titel="Differenz"
wert={abschluss.differenz}
warnen={Math.abs(differenz) > 0.005}
/>
</div>
{abschluss.erlaeuterung && (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
{abschluss.erlaeuterung}
</p>
)}
<div className="flex flex-wrap gap-2">
{abschluss.berichtDatei && (
<Button size="sm" variant="outline" asChild>
<a href={`/api/dateien/${abschluss.berichtDatei.id}`} target="_blank">
<Download aria-hidden />
Prüfbericht
</a>
</Button>
)}
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<Button
size="sm"
onClick={() => festschreiben.mutate({ id: abschluss.id })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Festschreiben
</Button>
)}
</div>
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<p className="text-xs text-[var(--muted-foreground)]">
Festschreiben schließt den Zeitraum: die Buchungen darin lassen sich danach
nicht mehr ändern, und ein neues Datum in diesem Zeitraum wird abgelehnt.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
{darfAbschliessen && (
<AbschlussAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={() => {
setAnlegenOffen(false);
setHinweis(
"Angelegt — noch als Entwurf. Die Prüfer können ihre Zahlen korrigieren, solange sie beisammensitzen.",
);
void werkzeuge.kassenabschluss.invalidate();
}}
/>
)}
</div>
);
}
function Wert({
titel,
wert,
warnen = false,
}: {
titel: string;
wert: string | { toString(): string };
warnen?: boolean;
}) {
return (
<div>
<p className="text-xs text-[var(--muted-foreground)]">{titel}</p>
<p
className={`text-lg font-semibold tabular-nums ${
warnen ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</div>
);
}
/**
* Die Prüfungsmaske.
*
* Links steht, was das Buch sagt; rechts wird eingetragen, was gezählt wurde.
* In dieser Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis
* es passt.
*
* Das ist der Grund, warum die Buchbestände hier zwar zu sehen sind (der
* Prüfer braucht sie für die Differenz), die gezählten Werte aber leer
* anfangen und nicht vorbelegt sind.
*/
function AbschlussAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const jahr = new Date().getFullYear() - 1;
const [vonAm, setVonAm] = React.useState(`${jahr}-01-01`);
const [bisAm, setBisAm] = React.useState(`${jahr}-12-31`);
const [prueferId, setPrueferId] = React.useState("");
const [zweiterId, setZweiterId] = React.useState("");
const [geprueftAm, setGeprueftAm] = React.useState(new Date().toISOString().slice(0, 10));
const [erlaeuterung, setErlaeuterung] = React.useState("");
const [gezaehlt, setGezaehlt] = React.useState<Record<string, string>>({});
const vorbereitung = trpc.kassenabschluss.vorbereiten.useQuery(
{ vonAm: new Date(vonAm), bisAm: new Date(bisAm) },
{ enabled: offen && Boolean(vonAm) && Boolean(bisAm) },
);
const mitglieder = trpc.person.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: offen },
);
const anlegen = trpc.kassenabschluss.anlegen.useMutation({
onSuccess: () => {
setGezaehlt({});
setErlaeuterung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
const posten = vorbereitung.data?.posten ?? [];
const alleGezaehlt = posten.every((eintrag) => gezaehlt[eintrag.kontoId]?.trim());
const summeGezaehlt = posten.reduce(
(gesamt, eintrag) => gesamt + Number(gezaehlt[eintrag.kontoId] || 0),
0,
);
const differenz = summeGezaehlt - Number(vorbereitung.data?.gesamt ?? 0);
const abweichung = Math.abs(differenz) > 0.005;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Kassenprüfung</DialogTitle>
<DialogDescription>
Erst zählen, dann eintragen. Der Buchbestand steht daneben, damit die Differenz
sichtbar wird — und nicht, damit die gezählte Zahl daran angepasst wird.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-von">Zeitraum von</Label>
<Input
id="abschluss-von"
type="date"
value={vonAm}
onChange={(ereignis) => setVonAm(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-bis">bis</Label>
<Input
id="abschluss-bis"
type="date"
value={bisAm}
onChange={(ereignis) => setBisAm(ereignis.target.value)}
/>
</div>
</div>
{(vorbereitung.data?.offeneBuchungen ?? 0) > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
{vorbereitung.data?.offeneBuchungen} Buchungen im Zeitraum sind noch nicht
festgeschrieben. Das Festschreiben des Abschlusses nimmt sie mit — wer sie vorher
durchsieht, prüft einen Stand, der sich danach nicht mehr ändert.
</div>
)}
{posten.length > 0 && (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Laut Buch</th>
<th className="px-3 py-2 text-right">Gezählt</th>
</tr>
</thead>
<tbody>
{posten.map((eintrag) => {
const wert = gezaehlt[eintrag.kontoId] ?? "";
const einzelDifferenz = wert
? Number(wert) - Number(eintrag.bestandLautBuch)
: 0;
return (
<tr
key={eintrag.kontoId}
className="border-b border-[var(--border)] last:border-0"
>
<td className="px-3 py-2">
{eintrag.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
im Zeitraum +{alsBetrag(eintrag.einnahmen)} / −
{alsBetrag(eintrag.ausgaben)}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(eintrag.bestandLautBuch)}
</td>
<td className="px-3 py-2 text-right">
<Input
type="number"
step="0.01"
className="w-32 text-right"
value={wert}
onChange={(ereignis) =>
setGezaehlt((vorher) => ({
...vorher,
[eintrag.kontoId]: ereignis.target.value,
}))
}
aria-label={`Gezählter Bestand ${eintrag.bezeichnung}`}
/>
{wert && Math.abs(einzelDifferenz) > 0.005 && (
<span className="block text-xs text-[var(--destructive)]">
{einzelDifferenz > 0 ? "+" : ""}
{alsBetrag(einzelDifferenz.toFixed(2))}
</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-[var(--border)] font-medium">
<td className="px-3 py-2">Summe</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(vorbereitung.data?.gesamt)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(summeGezaehlt.toFixed(2))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{abweichung && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
Differenz von <strong>{alsBetrag(differenz.toFixed(2))}</strong>. Sie muss erklärt
werden, sonst lässt sich der Abschluss nicht festschreiben — in fünf Jahren weiß
sonst niemand mehr, woher sie kam.
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-pruefer">Kassenprüfer</Label>
<Select value={prueferId} onValueChange={setPrueferId}>
<SelectTrigger id="abschluss-pruefer">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-zweiter">Zweiter Prüfer</Label>
<Select value={zweiterId} onValueChange={setZweiterId}>
<SelectTrigger id="abschluss-zweiter">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-geprueft">Geprüft am</Label>
<Input
id="abschluss-geprueft"
type="date"
value={geprueftAm}
onChange={(ereignis) => setGeprueftAm(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-erlaeuterung">
Erläuterung {abweichung && <span className="text-[var(--destructive)]">*</span>}
</Label>
<Textarea
id="abschluss-erlaeuterung"
rows={3}
value={erlaeuterung}
onChange={(ereignis) => setErlaeuterung(ereignis.target.value)}
placeholder="Bar 3,20 € weniger — Wechselgeld vom Maifest, Beleg fehlt."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
vonAm: new Date(vonAm),
bisAm: new Date(bisAm),
prueferId: prueferId || null,
zweiterPrueferId: zweiterId || null,
geprueftAm: geprueftAm ? new Date(geprueftAm) : null,
erlaeuterung: erlaeuterung.trim() || null,
posten: posten.map((eintrag) => ({
kontoId: eintrag.kontoId,
gezaehlterBestand: Number(gezaehlt[eintrag.kontoId] ?? 0),
})),
})
}
disabled={posten.length === 0 || !alleGezaehlt || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Jahreszahlen für die Jahreshauptversammlung. */
function Auswertung() {
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [exportLaeuft, setExportLaeuft] = React.useState(false);
const werkzeuge = trpc.useUtils();
const auswertung = trpc.kassenabschluss.auswertung.useQuery({ jahr });
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
async function herunterladen() {
setExportLaeuft(true);
try {
const datei = await werkzeuge.kassenabschluss.export.fetch({ jahr });
// Der Umweg über einen Blob statt eines Links auf eine API-Route: die
// Daten liegen bereits vor, und so entsteht kein zweiter Weg an der
// Rechteprüfung vorbei.
const ziel = URL.createObjectURL(
new Blob([datei.inhalt], { type: "text/csv;charset=utf-8" }),
);
const verweis = document.createElement("a");
verweis.href = ziel;
verweis.download = datei.dateiname;
verweis.click();
URL.revokeObjectURL(ziel);
} finally {
setExportLaeuft(false);
}
}
const einnahmen = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.EINNAHME,
);
const ausgaben = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.AUSGABE,
);
const hoechster = Math.max(
1,
...(auswertung.data?.monate.flatMap((monat) => [
Number(monat.einnahmen),
Number(monat.ausgaben),
]) ?? [1]),
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => void herunterladen()}
disabled={exportLaeuft}
>
<FileText aria-hidden />
{exportLaeuft ? "Wird erzeugt …" : "Kassenbuch als CSV"}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Vergleich
titel="Einnahmen"
wert={auswertung.data?.gesamt.einnahmen}
vorjahr={auswertung.data?.vorjahr.einnahmen}
jahr={jahr}
/>
<Vergleich
titel="Ausgaben"
wert={auswertung.data?.gesamt.ausgaben}
vorjahr={auswertung.data?.vorjahr.ausgaben}
jahr={jahr}
/>
<Vergleich
titel="Saldo"
wert={auswertung.data?.gesamt.saldo}
vorjahr={auswertung.data?.vorjahr.saldo}
jahr={jahr}
/>
</div>
{auswertung.data?.anzahl === 0 && (
<NichtsGefunden
titel={`Für ${jahr} steht nichts im Kassenbuch`}
hinweis="Ohne Buchungen gibt es nichts auszuwerten."
/>
)}
{(auswertung.data?.anzahl ?? 0) > 0 && (
<>
{/* Der Jahresverlauf. Zwölf Balkenpaare, kein Diagrammpaket —
das reicht, um zu sehen, wo das Fest war. */}
<Card>
<CardHeader>
<CardTitle className="text-base">Verlauf {jahr}</CardTitle>
<CardDescription>
Einnahmen und Ausgaben je Monat, gemessen am größten Monatswert.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{auswertung.data?.monate.map((monat) => (
<div key={monat.monat} className="flex items-center gap-3 text-xs">
<span className="w-20 shrink-0 text-[var(--muted-foreground)]">
{MONATE[monat.monat - 1]}
</span>
<div className="flex flex-1 flex-col gap-0.5">
<div
className="h-2 rounded-sm bg-[var(--einsatzbereit)]"
style={{ width: `${(Number(monat.einnahmen) / hoechster) * 100}%` }}
/>
<div
className="h-2 rounded-sm bg-[var(--destructive)]"
style={{ width: `${(Number(monat.ausgaben) / hoechster) * 100}%` }}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums">
{alsBetrag(monat.saldo)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Kategorieliste titel="Einnahmen nach Kategorie" zeilen={einnahmen} />
<Kategorieliste titel="Ausgaben nach Kategorie" zeilen={ausgaben} />
</div>
</>
)}
</div>
);
}
function Vergleich({
titel,
wert,
vorjahr,
jahr,
}: {
titel: string;
wert: string | undefined;
vorjahr: string | undefined;
jahr: number;
}) {
const jetzt = Number(wert ?? 0);
const davor = Number(vorjahr ?? 0);
const unterschied = jetzt - davor;
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{jahr - 1}: {alsBetrag(vorjahr)}
{davor !== 0 && (
<>
{" · "}
{unterschied >= 0 ? "+" : "−"}
{alsBetrag(Math.abs(unterschied).toFixed(2))}
</>
)}
</p>
</CardContent>
</Card>
);
}
function Kategorieliste({
titel,
zeilen,
}: {
titel: string;
zeilen: Array<{ id: string; name: string; oberkategorie: string | null; summe: string }> | undefined;
}) {
const gesamt = zeilen?.reduce((wert, zeile) => wert + Number(zeile.summe), 0) ?? 0;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{(zeilen?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts gebucht.</p>
) : (
<ul className="space-y-1.5 text-sm">
{zeilen?.map((zeile) => (
<li key={zeile.id}>
<div className="flex justify-between gap-2">
<span>
{zeile.name}
{zeile.oberkategorie && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.oberkategorie}
</span>
)}
</span>
<span className="tabular-nums">{alsBetrag(zeile.summe)}</span>
</div>
<div
className="mt-0.5 h-1 rounded-sm bg-[var(--primary)]/40"
style={{ width: `${gesamt > 0 ? (Number(zeile.summe) / gesamt) * 100 : 0}%` }}
/>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Abschlussansicht } from "@/app/(app)/kasse/abschluss/ansicht";
export const metadata = { title: "Kassenabschluss" };
export default async function AbschlussSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Abschluss und Auswertung</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kassenprüfung, Jahreszahlen und der Export für Steuerberater oder Kämmerei.
</p>
</div>
<Abschlussansicht darfAbschliessen={hatRecht(sitzung, "kasse.abschluss")} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Beitragsverwaltung } from "@/app/(app)/kasse/beitraege/verwaltung";
export const metadata = { title: "Beiträge" };
export default async function BeitraegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitgliedsbeiträge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Sätze, Beitragslauf und wer noch nicht gezahlt hat.
</p>
</div>
<Beitragsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,735 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft, Check, Mail, PlayCircle, Plus } from "lucide-react";
import { BeitragStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BEITRAG_STAND_TEXT, BEITRAG_STAND_VARIANTE, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Beitragsverwaltung.
*
* Der Ablauf, wie er im Januar wirklich stattfindet:
*
* 1. Sätze prüfen — sind sie noch aktuell?
* 2. Beitragslauf starten. Vorher steht da, wie viele Beiträge entstehen und
* wer keinen Satz hat.
* 3. Zahlungen abhaken. Jede erzeugt eine Buchung.
* 4. Im Herbst mahnen, was offen ist.
*
* Die Liste ohne Satz ist der interessante Teil der Vorschau: das sind die
* Leute, für die niemand entschieden hat, was sie zahlen.
*/
export function Beitragsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [stand, setStand] = React.useState<BeitragStand | "alle">("alle");
const [suche, setSuche] = React.useState("");
const [laufOffen, setLaufOffen] = React.useState(false);
const [satzOffen, setSatzOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{ id: string; name: string; betrag: string } | null>(
null,
);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(new Set());
const saetze = trpc.beitrag.saetze.useQuery();
const beitraege = trpc.beitrag.liste.useQuery({
jahr,
stand: stand === "alle" ? undefined : stand,
suche: suche.trim() || undefined,
});
const rueckstaende = trpc.beitrag.rueckstaende.useQuery();
const mahnen = trpc.beitrag.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Beiträge gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort ist die fünfte Mahnstufe erreicht, und was danach kommt, entscheidet die Kommandantschaft.`
: ""),
);
setGewaehlt(new Set());
void werkzeuge.beitrag.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const erlassen = trpc.beitrag.erlassen.useMutation({
onSuccess: () => void werkzeuge.beitrag.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = beitraege.data?.beitraege ?? [];
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="beitragsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl titel="Sollstellung" wert={beitraege.data?.kennzahlen.gesamt} />
<Kennzahl titel="Bezahlt" wert={beitraege.data?.kennzahlen.bezahlt} />
<Kennzahl
titel="Offen"
wert={beitraege.data?.kennzahlen.offen}
hinweis={`${beitraege.data?.kennzahlen.anzahlOffen ?? 0} Mitglieder`}
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Kennzahl titel="Erlassen" wert={beitraege.data?.kennzahlen.erlassen} />
</div>
{/* Rückstände über alle Jahre — die Liste für die Jahreshauptversammlung */}
{(rueckstaende.data?.zeilen.length ?? 0) > 0 && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4" aria-hidden />
Rückstände über alle Jahre
</CardTitle>
<CardDescription>
{rueckstaende.data?.anzahl} offene Beiträge über{" "}
{alsBetrag(rueckstaende.data?.gesamt)}. Wer über mehrere Jahre offen steht, fällt
hier auf — und sonst nirgends.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{rueckstaende.data?.zeilen.slice(0, 10).map((zeile) => (
<li key={zeile.person.id} className="flex flex-wrap justify-between gap-2">
<span>
{zeile.person.nachname}, {zeile.person.vorname}
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.jahre.join(", ")}
{zeile.hoechsteMahnstufe > 0 && ` · ${zeile.hoechsteMahnstufe}. Mahnung`}
</span>
</span>
<span className="tabular-nums font-medium">{alsBetrag(zeile.summe)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Sätze */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Beitragssätze</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setSatzOffen(true)}>
<Plus aria-hidden />
Satz anlegen
</Button>
)}
</div>
{(saetze.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Sätze"
hinweis="Ohne Satz weiß FLORIAN nicht, was ein Mitglied zahlt — der Beitragslauf legt dann nichts an."
/>
) : (
<div className="flex flex-wrap gap-2">
{saetze.data?.map((satz) => (
<div
key={satz.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-2 text-sm"
>
<span className="font-medium">{satz.bezeichnung}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsBetrag(satz.betrag)} ab {satz.abJahr}
{satz.abteilung ? ` · ${satz.abteilung}` : " · alle Abteilungen"}
</span>
</div>
))}
</div>
)}
</section>
{/* Liste */}
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as BeitragStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(BeitragStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{BEITRAG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name oder Mitgliedsnummer"
className="min-w-48 flex-1"
aria-label="Mitglieder durchsuchen"
/>
{darfBearbeiten && (
<div className="ml-auto flex gap-2">
{gewaehlt.size > 0 && (
<Button
variant="outline"
onClick={() => mahnen.mutate({ ids: [...gewaehlt] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
{gewaehlt.size} mahnen
</Button>
)}
<Button onClick={() => setLaufOffen(true)}>
<PlayCircle aria-hidden />
Beitragslauf {jahr}
</Button>
</div>
)}
</div>
{!beitraege.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel={`Keine Beiträge für ${jahr}`}
hinweis={
darfBearbeiten
? "Der Beitragslauf legt sie für alle aktiven Mitglieder an."
: "Für dieses Jahr wurde noch nicht abgerechnet."
}
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
{darfBearbeiten && <th className="px-3 py-2" />}
<th className="px-3 py-2">Mitglied</th>
<th className="px-3 py-2">Abteilung</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2">Bezahlt</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((beitrag) => {
const offen =
beitrag.stand === BeitragStand.OFFEN || beitrag.stand === BeitragStand.GEMAHNT;
return (
<tr key={beitrag.id} className="border-b border-[var(--border)] last:border-0">
{darfBearbeiten && (
<td className="px-3 py-2">
{offen && (
<input
type="checkbox"
checked={gewaehlt.has(beitrag.id)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(beitrag.id);
else neu.delete(beitrag.id);
setGewaehlt(neu);
}}
aria-label={`${beitrag.person.nachname} auswählen`}
/>
)}
</td>
)}
<td className="px-3 py-2">
{beitrag.person.nachname}, {beitrag.person.vorname}
<span className="block text-xs text-[var(--muted-foreground)]">
{beitrag.person.mitgliedsnummer}
</span>
</td>
<td className="px-3 py-2">{beitrag.person.abteilung ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(beitrag.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={BEITRAG_STAND_VARIANTE[beitrag.stand]}>
{BEITRAG_STAND_TEXT[beitrag.stand]}
</Badge>
{beitrag.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{beitrag.mahnstufe}.
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{beitrag.bezahltAm ? alsDatum(beitrag.bezahltAm) : "—"}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: beitrag.id,
name: `${beitrag.person.nachname}, ${beitrag.person.vorname}`,
betrag: String(beitrag.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird der Beitrag erlassen? Ohne Begründung ist er in der Kassenprüfung eine offene Frage.",
);
if (grund?.trim()) {
erlassen.mutate({ id: beitrag.id, grund: grund.trim() });
}
}}
>
Erlassen
</Button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<>
<BeitragslaufDialog
offen={laufOffen}
jahr={jahr}
schliessen={() => setLaufOffen(false)}
fehler={setMeldung}
fertig={(angelegt) => {
setLaufOffen(false);
setHinweis(`${angelegt} Beiträge für ${jahr} angelegt.`);
void werkzeuge.beitrag.invalidate();
}}
/>
<SatzDialog
offen={satzOffen}
schliessen={() => setSatzOffen(false)}
fehler={setMeldung}
fertig={() => {
setSatzOffen(false);
void werkzeuge.beitrag.invalidate();
}}
/>
<BezahlenDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.beitrag.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/** Der Beitragslauf mit Vorschau. */
function BeitragslaufDialog({
offen,
jahr,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
jahr: number;
schliessen: () => void;
fertig: (angelegt: number) => void;
fehler: (nachricht: string) => void;
}) {
const vorschau = trpc.beitrag.laufVorschau.useQuery({ jahr }, { enabled: offen });
const lauf = trpc.beitrag.lauf.useMutation({
onSuccess: (ergebnis) => fertig(ergebnis.angelegt),
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Beitragslauf {jahr}</DialogTitle>
<DialogDescription>
Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — der Lauf lässt
sich also im Februar noch einmal starten, wenn Neue dazugekommen sind.
</DialogDescription>
</DialogHeader>
{vorschau.isLoading && <p className="text-sm">Wird gerechnet …</p>}
{vorschau.data && (
<div className="space-y-4 text-sm">
<div className="rounded-md bg-[var(--muted)] p-3">
<p>
<strong>{vorschau.data.anzulegen.length} Beiträge</strong> werden angelegt, Summe{" "}
<strong>{alsBetrag(vorschau.data.summe)}</strong>.
</p>
{vorschau.data.bereitsVorhanden > 0 && (
<p className="text-[var(--muted-foreground)]">
{vorschau.data.bereitsVorhanden} Mitglieder haben für {jahr} bereits einen
Beitrag — die bleiben unberührt.
</p>
)}
</div>
{vorschau.data.ohneSatz.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="font-medium">
{vorschau.data.ohneSatz.length} Mitglieder ohne passenden Satz
</p>
<p className="mt-1 text-[var(--muted-foreground)]">
Für sie entsteht kein Beitrag. Entweder fehlt ein Satz für ihre Abteilung, oder
es gibt keinen allgemeinen.
</p>
<ul className="mt-2 list-disc pl-5">
{vorschau.data.ohneSatz.slice(0, 8).map((eintrag) => (
<li key={eintrag.personId}>
{eintrag.name}
{eintrag.abteilung && ` (${eintrag.abteilung})`}
</li>
))}
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => lauf.mutate({ jahr })}
disabled={!vorschau.data?.anzulegen.length || lauf.isPending}
>
{lauf.isPending ? "Läuft …" : `${vorschau.data?.anzulegen.length ?? 0} anlegen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SatzDialog({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [betrag, setBetrag] = React.useState("");
const [abteilung, setAbteilung] = React.useState("");
const [abJahr, setAbJahr] = React.useState(String(new Date().getFullYear()));
const anlegen = trpc.beitrag.satzAnlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setBetrag("");
setAbteilung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitragssatz anlegen</DialogTitle>
<DialogDescription>
Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert — sonst stimmen die
Beiträge vergangener Jahre nicht mehr, und die sind bereits geprüft.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="satz-bezeichnung">Bezeichnung</Label>
<Input
id="satz-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Aktive Mitglieder"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="satz-betrag">Betrag je Jahr</Label>
<Input
id="satz-betrag"
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-abteilung">Abteilung</Label>
<Input
id="satz-abteilung"
value={abteilung}
onChange={(ereignis) => setAbteilung(ereignis.target.value)}
placeholder="leer = alle"
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-jahr">Ab Jahr</Label>
<Input
id="satz-jahr"
type="number"
value={abJahr}
onChange={(ereignis) => setAbJahr(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
betrag: Number(betrag),
abteilung: abteilung.trim() || null,
abJahr: Number(abJahr),
aktiv: true,
})
}
disabled={!bezeichnung.trim() || !betrag || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang — erzeugt zugleich die Buchung. */
function BezahlenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; name: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.beitrag.bezahlen.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
// Die Kategorie „Mitgliedsbeiträge" vorbelegen — sie ist fast immer die
// richtige und kommt aus dem Standardkontenrahmen.
if (!kategorieId && kategorien.data) {
const treffer = kategorien.data.find((eintrag) =>
eintrag.name.toLowerCase().includes("beitr"),
);
if (treffer) setKategorieId(treffer.id);
}
}, [kontoId, kategorieId, konten.data, kategorien.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitrag als bezahlt vermerken</DialogTitle>
<DialogDescription>
{eintrag?.name} · {alsBetrag(eintrag?.betrag)} — es entsteht zugleich die Buchung.
Ein Häkchen ohne Geld in der Kasse wäre eine Behauptung.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bezahlt-datum">Bezahlt am</Label>
<Input
id="bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,929 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Lock, Plus, Search, Sparkles, Undo2, Upload } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BUCHUNGSART_TEXT, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Das Kassenbuch.
*
* Oben die Summen über **alle** Treffer, nicht nur über die angezeigten —
* eine Summe unter einer auf hundert Zeilen begrenzten Liste bedeutet sonst
* nichts.
*
* Festgeschriebene Buchungen tragen ein Schloss. Sie lassen sich weder ändern
* noch löschen; wer sich vertan hat, bucht dagegen. Beide bleiben stehen, und
* im Kassenbuch ist zu sehen, dass etwas berichtigt wurde — genau das will ein
* Kassenprüfer sehen.
*/
export function BuchungsListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [suche, setSuche] = React.useState("");
const [kontoId, setKontoId] = React.useState<string | "alle">("alle");
const [art, setArt] = React.useState<BuchungsArt | "alle">("alle");
const [nurOffene, setNurOffene] = React.useState(false);
const [erfassenOffen, setErfassenOffen] = React.useState(false);
const [importOffen, setImportOffen] = React.useState(false);
const [gegenbuchen, setGegenbuchen] = React.useState<{ id: string; zweck: string } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const buchungen = trpc.buchung.liste.useQuery({
suche: suche.trim() || undefined,
kontoId: kontoId === "alle" ? undefined : kontoId,
art: art === "alle" ? undefined : art,
nurOffene,
anzahl: 200,
});
const festschreiben = trpc.buchung.festschreiben.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
ergebnis.festgeschrieben === 0
? "Es war nichts festzuschreiben."
: `${ergebnis.festgeschrieben} Buchungen festgeschrieben. Sie sind ab jetzt unveränderbar.`,
);
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.buchung.loeschen.useMutation({
onSuccess: () => void werkzeuge.buchung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = buchungen.data?.buchungen ?? [];
const offene = eintraege.filter((eintrag) => !eintrag.festgeschriebenAm).length;
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="buchungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* Summen über alle Treffer */}
<div className="grid gap-3 sm:grid-cols-3">
<Summe titel="Einnahmen" wert={buchungen.data?.summe.einnahmen} />
<Summe titel="Ausgaben" wert={buchungen.data?.summe.ausgaben} />
<Summe titel="Saldo" wert={buchungen.data?.summe.saldo} hervorheben />
</div>
<div className="flex flex-wrap items-center gap-3">
<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={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Verwendungszweck, Lieferant, Belegnummer …"
className="pl-9"
aria-label="Buchungen durchsuchen"
/>
</div>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger className="w-52" aria-label="Konto">
<SelectValue placeholder="Alle Konten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Konten</SelectItem>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={art} onValueChange={(wert) => setArt(wert as BuchungsArt | "alle")}>
<SelectTrigger className="w-40" aria-label="Art">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle</SelectItem>
{Object.values(BuchungsArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{BUCHUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={nurOffene ? "default" : "outline"}
size="sm"
onClick={() => setNurOffene((wert) => !wert)}
>
Nur Entwürfe
</Button>
{darfBearbeiten && (
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setImportOffen(true)}>
<Upload aria-hidden />
Kontoauszug
</Button>
<Button onClick={() => setErfassenOffen(true)}>
<Plus aria-hidden />
Buchung erfassen
</Button>
</div>
)}
</div>
{darfBearbeiten && offene > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<span className="flex-1">
{offene} {offene === 1 ? "Buchung ist" : "Buchungen sind"} noch nicht festgeschrieben.
Festgeschriebene lassen sich nicht mehr ändern — das ist der Zustand, den eine
Kassenprüfung erwartet.
</span>
<Button
size="sm"
variant="outline"
onClick={() => festschreiben.mutate({ bisAm: new Date() })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Alles bis heute festschreiben
</Button>
</div>
)}
{!buchungen.isLoading && eintraege.length === 0 && (
<NichtsGefunden
titel="Keine Buchungen"
hinweis={
suche || kontoId !== "alle" || art !== "alle" || nurOffene
? "Zu dieser Auswahl steht nichts im Kassenbuch."
: "Die erste Buchung entsteht über den Knopf oben rechts."
}
/>
)}
{eintraege.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{eintraege.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span>{buchung.verwendungszweck}</span>
{buchung.festgeschriebenAm && (
<Lock
className="size-3.5 text-[var(--muted-foreground)]"
aria-label="festgeschrieben"
/>
)}
{buchung.storniertVonId && (
<Badge variant="secondary">gegengebucht</Badge>
)}
</div>
{(buchung.lieferant || buchung.person) && (
<span className="text-xs text-[var(--muted-foreground)]">
{buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: buchung.lieferant}
</span>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === BuchungsArt.AUSGABE ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && !buchung.storniertVonId && (
<>
{buchung.festgeschriebenAm ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
setGegenbuchen({
id: buchung.id,
zweck: buchung.verwendungszweck,
})
}
>
<Undo2 aria-hidden />
Gegenbuchen
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: buchung.id })}
>
Verwerfen
</Button>
)}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<BuchungErfassen
offen={erfassenOffen}
schliessen={() => setErfassenOffen(false)}
fehler={setMeldung}
fertig={() => {
setErfassenOffen(false);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
<Kontoauszug
offen={importOffen}
schliessen={() => setImportOffen(false)}
fehler={setMeldung}
fertig={(anzahl) => {
setImportOffen(false);
setHinweis(`${anzahl} Buchungen als Entwurf übernommen. Bitte durchsehen.`);
void werkzeuge.buchung.invalidate();
}}
/>
<GegenbuchenDialog
eintrag={gegenbuchen}
schliessen={() => setGegenbuchen(null)}
fehler={setMeldung}
fertig={() => {
setGegenbuchen(null);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Summe({
titel,
wert,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hervorheben?: boolean;
}) {
const zahl = Number(wert ?? 0);
return (
<Card className={hervorheben ? "border-[var(--primary)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-xl font-semibold tabular-nums ${
hervorheben && zahl < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</CardContent>
</Card>
);
}
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Die Buchungsmaske.
*
* Der Kategorievorschlag ist die eine Bequemlichkeit, die hier wirklich etwas
* bringt: nicht wegen der gesparten Sekunden, sondern weil dieselbe Sache
* dadurch immer unter derselben Kategorie landet — und erst das macht die
* Jahresauswertung brauchbar.
*/
function BuchungErfassen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [art, setArt] = React.useState<BuchungsArt>(BuchungsArt.AUSGABE);
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(heute());
const [betrag, setBetrag] = React.useState("");
const [zweck, setZweck] = React.useState("");
const [lieferant, setLieferant] = React.useState("");
const [belegnummer, setBelegnummer] = React.useState("");
const [vorschlagUebernommen, setVorschlagUebernommen] = React.useState(false);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art });
const vorschlag = trpc.buchung.vorschlag.useQuery(
{ verwendungszweck: zweck, art },
{ enabled: offen && zweck.trim().length >= 3 },
);
const anlegen = trpc.buchung.anlegen.useMutation({
onSuccess: () => {
setBetrag("");
setZweck("");
setLieferant("");
setBelegnummer("");
setVorschlagUebernommen(false);
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
// Das erste Konto vorbelegen — die meisten Wehren buchen fast alles auf
// dasselbe.
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Beim Wechsel der Richtung passt die gewählte Kategorie nicht mehr.
React.useEffect(() => {
setKategorieId("");
setVorschlagUebernommen(false);
}, [art]);
const passenderVorschlag =
vorschlag.data && !kategorieId && !vorschlagUebernommen ? vorschlag.data : null;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Buchung erfassen</DialogTitle>
<DialogDescription>
Der Betrag ist immer positiv — die Richtung steht daneben. Die Buchung ist zunächst
ein Entwurf und lässt sich noch ändern.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
{Object.values(BuchungsArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{BUCHUNGSART_TEXT[wert]}
</Button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-datum">Buchungsdatum</Label>
<Input
id="buchung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-betrag">Betrag</Label>
<Input
id="buchung-betrag"
type="number"
step="0.01"
min="0.01"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
placeholder="0,00"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-zweck">Verwendungszweck</Label>
<Textarea
id="buchung-zweck"
rows={2}
value={zweck}
onChange={(ereignis) => setZweck(ereignis.target.value)}
placeholder="Tankstelle Musterhausen, Diesel LF 20"
/>
</div>
{passenderVorschlag && (
<button
type="button"
onClick={() => {
setKategorieId(passenderVorschlag.kategorieId);
setVorschlagUebernommen(true);
}}
className="flex w-full items-center gap-2 rounded-md border border-[var(--primary)] bg-[var(--primary)]/5 p-3 text-left text-sm hover:bg-[var(--primary)]/10"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
<span>
Vorschlag: <strong>{passenderVorschlag.name}</strong>
<span className="block text-xs text-[var(--muted-foreground)]">
{passenderVorschlag.grund}
</span>
</span>
</button>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="buchung-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-konto">Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="buchung-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-lieferant">
{art === BuchungsArt.AUSGABE ? "Empfänger" : "Einzahler"}
</Label>
<Input
id="buchung-lieferant"
value={lieferant}
onChange={(ereignis) => setLieferant(ereignis.target.value)}
placeholder="Werkstatt Müller GmbH"
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-beleg">Belegnummer</Label>
<Input
id="buchung-beleg"
value={belegnummer}
onChange={(ereignis) => setBelegnummer(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
kontoId,
kategorieId,
datum: new Date(datum),
art,
betrag: Number(betrag),
verwendungszweck: zweck,
lieferant: lieferant || null,
belegnummer: belegnummer || null,
})
}
disabled={
!kontoId || !kategorieId || !zweck.trim() || !(Number(betrag) > 0) || anlegen.isPending
}
>
{anlegen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Kontoauszug einlesen.
*
* Der eigentliche Wert ist die Spalte „schon da": ein zweimal eingelesener
* Auszug wäre sonst ein doppelt gebuchter Monat, und das fällt erst beim
* Kassenabschluss auf.
*/
function Kontoauszug({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (anzahl: number) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [gelesen, setGelesen] = React.useState<{
format: string;
konto: string | null;
fehler: Array<{ stelle: string; grund: string }>;
posten: Array<{
datum: Date;
art: BuchungsArt;
betrag: string;
verwendungszweck: string;
gegenseite: string | null;
schonDa: boolean;
}>;
bekannt: number;
} | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<number>>(new Set());
const [kategorieId, setKategorieId] = React.useState("");
const dateiRef = React.useRef<HTMLInputElement>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery();
const lesen = trpc.buchung.auszugLesen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(ergebnis);
// Vorausgewählt ist, was noch nicht da ist — das ist fast immer das,
// was jemand übernehmen will.
setGewaehlt(
new Set(
ergebnis.posten
.map((posten, stelle) => (posten.schonDa ? -1 : stelle))
.filter((stelle) => stelle >= 0),
),
);
},
onError: (aufruf) => fehler(aufruf.message),
});
const uebernehmen = trpc.buchung.auszugUebernehmen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(null);
setGewaehlt(new Set());
fertig(ergebnis.uebernommen);
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
async function dateiWaehlen(datei: File) {
const inhalt = await datei.text();
lesen.mutate({ kontoId, inhalt });
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Kontoauszug einlesen</DialogTitle>
<DialogDescription>
CAMT.053 (XML) oder MT940 — beides bietet das Online-Banking unter „Umsatzexport“ an.
Es entstehen <strong>Entwürfe</strong>, keine fertigen Buchungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-2">
<Label htmlFor="auszug-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="auszug-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<input
ref={dateiRef}
type="file"
accept=".xml,.camt,.txt,.sta,.mt940,text/xml,text/plain"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void dateiWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button
onClick={() => dateiRef.current?.click()}
disabled={!kontoId || lesen.isPending}
>
<Upload aria-hidden />
{lesen.isPending ? "Wird gelesen …" : "Datei wählen"}
</Button>
</div>
{gelesen && (
<>
<p className="text-sm">
Format <strong>{gelesen.format}</strong>
{gelesen.konto && ` · Konto ${gelesen.konto}`} · {gelesen.posten.length} Umsätze
{gelesen.bekannt > 0 && ` · ${gelesen.bekannt} davon stehen schon in der Kasse`}
</p>
{gelesen.fehler.length > 0 && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<p className="font-medium">Nicht gelesen werden konnte:</p>
<ul className="mt-1 list-disc pl-5">
{gelesen.fehler.slice(0, 8).map((eintrag, stelle) => (
<li key={stelle}>
{eintrag.stelle}: {eintrag.grund}
</li>
))}
</ul>
</div>
)}
{gelesen.posten.length > 0 && (
<>
<div className="space-y-2">
<Label htmlFor="auszug-kategorie">Kategorie für alle übernommenen</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="auszug-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{BUCHUNGSART_TEXT[kategorie.art]} · {kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Sie lässt sich danach je Buchung ändern — die Entwürfe sind dafür da.
</p>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--card)]">
<tr className="border-b border-[var(--border)] text-left">
<th className="px-2 py-1.5" />
<th className="px-2 py-1.5">Datum</th>
<th className="px-2 py-1.5">Verwendungszweck</th>
<th className="px-2 py-1.5 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{gelesen.posten.map((posten, stelle) => (
<tr
key={stelle}
className={`border-b border-[var(--border)] last:border-0 ${
posten.schonDa ? "opacity-50" : ""
}`}
>
<td className="px-2 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(stelle)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(stelle);
else neu.delete(stelle);
setGewaehlt(neu);
}}
aria-label={`Umsatz ${stelle + 1} übernehmen`}
/>
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{alsDatum(posten.datum)}
</td>
<td className="px-2 py-1.5">
{posten.verwendungszweck.slice(0, 80)}
{posten.gegenseite && (
<span className="block text-xs text-[var(--muted-foreground)]">
{posten.gegenseite}
</span>
)}
{posten.schonDa && (
<Badge variant="secondary" className="mt-1">
steht schon in der Kasse
</Badge>
)}
</td>
<td
className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${
posten.art === BuchungsArt.AUSGABE
? "text-[var(--destructive)]"
: ""
}`}
>
{posten.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(posten.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
uebernehmen.mutate({
kontoId,
posten: [...gewaehlt].map((stelle) => {
const posten = gelesen!.posten[stelle]!;
return {
datum: posten.datum,
art: posten.art,
betrag: Number(posten.betrag),
verwendungszweck: posten.verwendungszweck.slice(0, 300),
gegenseite: posten.gegenseite,
kategorieId,
};
}),
})
}
disabled={gewaehlt.size === 0 || !kategorieId || uebernehmen.isPending}
>
{uebernehmen.isPending
? "Wird übernommen …"
: `${gewaehlt.size} übernehmen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Gegenbuchen — mit Grund, denn er steht später im Verwendungszweck. */
function GegenbuchenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; zweck: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [grund, setGrund] = React.useState("");
const gegenbuchen = trpc.buchung.gegenbuchen.useMutation({
onSuccess: () => {
setGrund("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gegenbuchen</DialogTitle>
<DialogDescription>
Es entsteht eine zweite Buchung mit derselben Summe in die andere Richtung, mit
Verweis auf die erste. Beide bleiben stehen — genau das will ein Kassenprüfer sehen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-[var(--muted)] p-3 text-sm">{eintrag?.zweck}</div>
<div className="space-y-2">
<Label htmlFor="gegenbuchung-grund">Warum?</Label>
<Input
id="gegenbuchung-grund"
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Betrag falsch erfasst"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Der Grund steht im Verwendungszweck der Gegenbuchung. Das Buchungsdatum ist heute —
ein geprüfter Zeitraum bleibt zu.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => eintrag && gegenbuchen.mutate({ id: eintrag.id, grund })}
disabled={grund.trim().length < 3 || gegenbuchen.isPending}
>
{gegenbuchen.isPending ? "Wird gebucht …" : "Gegenbuchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BuchungsListe } from "@/app/(app)/kasse/buchungen/buchungs-liste";
export const metadata = { title: "Buchungen" };
export default async function BuchungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Buchungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Das Kassenbuch. Festgeschriebene Buchungen sind unveränderbar — Korrekturen laufen über
eine Gegenbuchung.
</p>
</div>
<BuchungsListe darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KassenUebersicht } from "@/app/(app)/kasse/uebersicht";
export const metadata = { title: "Kasse" };
/**
* Die Kasse der Wehr.
*
* Oben die Konten mit ihrem Bestand — die Zahl, die der Kassier zuerst sehen
* will. Darunter, was zu tun ist: Entwürfe, offene Beiträge, überfällige
* Rechnungen.
*/
export default async function KasseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kasse</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Konten, Buchungen, Beiträge und der Kassenabschluss.
</p>
</div>
<KassenUebersicht
darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")}
darfAbschluss={hatRecht(sitzung, "kasse.abschluss")}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Rechnungsverwaltung } from "@/app/(app)/kasse/rechnungen/verwaltung";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rechnungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kostenersatz für Fehlalarme und Öleinsätze, Vermietungen, Veranstaltungen.
</p>
</div>
<Rechnungsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,824 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Check, Download, FileText, Mail, Plus, Send, Trash2, X } from "lucide-react";
import { RechnungsAnlass, RechnungStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import {
ANLASS_TEXT,
RECHNUNG_STAND_TEXT,
RECHNUNG_STAND_VARIANTE,
alsBetrag,
} from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
type Position = {
bezeichnung: string;
menge: string;
einheit: string;
einzelpreis: string;
};
const LEERE_POSITION: Position = { bezeichnung: "", menge: "1", einheit: "", einzelpreis: "" };
/**
* Rechnungen.
*
* Eine Rechnung ist bis zum Versenden ein Entwurf und danach nicht mehr
* änderbar. Der Grund ist derselbe wie beim Kassenbuch: was beim Empfänger
* liegt, muss dem entsprechen, was hier steht. Korrigiert wird über Storno
* und eine neue Rechnung — mit eigener Nummer, damit keine Lücke entsteht.
*/
export function Rechnungsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [stand, setStand] = React.useState<RechnungStand | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [suche, setSuche] = React.useState("");
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{
id: string;
nummer: string;
betrag: string;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const rechnungen = trpc.rechnung.liste.useQuery({
stand: stand === "alle" ? undefined : stand,
jahr: jahr === "alle" ? undefined : jahr,
suche: suche.trim() || undefined,
});
const versenden = trpc.rechnung.versenden.useMutation({
onSuccess: () => {
setHinweis("Versendet. Das PDF entsteht im Hintergrund und steht gleich zum Herunterladen.");
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const mahnen = trpc.rechnung.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Rechnungen gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort steht die dritte Mahnung, danach ist es eine Frage für die Kommandantschaft.`
: ""),
);
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const stornieren = trpc.rechnung.stornieren.useMutation({
onSuccess: () => void werkzeuge.rechnung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const pdfErzeugen = trpc.rechnung.pdfErzeugen.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = rechnungen.data?.rechnungen ?? [];
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="rechnungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Kennzahl titel="Gestellt" wert={rechnungen.data?.kennzahlen.gesamt} />
<Kennzahl
titel="Offen"
wert={rechnungen.data?.kennzahlen.offen}
hinweis={`${rechnungen.data?.kennzahlen.anzahlOffen ?? 0} Rechnungen`}
/>
<Card
className={
(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0
? "border-[var(--gesperrt)]"
: undefined
}
>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
Überfällig
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">
{rechnungen.data?.kennzahlen.ueberfaellig ?? 0}
</p>
<p className="text-xs text-[var(--muted-foreground)]">Zahlungsziel verstrichen</p>
</CardContent>
</Card>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Empfänger oder Rechnungsnummer"
className="min-w-52 flex-1"
aria-label="Rechnungen durchsuchen"
/>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Jahre</SelectItem>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as RechnungStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(RechnungStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{RECHNUNG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button className="ml-auto" onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Rechnung schreiben
</Button>
)}
</div>
{!rechnungen.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel="Keine Rechnungen"
hinweis="Kostenersatz nach Art. 28 BayFwG wird hier geschrieben — Fehlalarme, Öleinsätze, Vermietungen."
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Nummer</th>
<th className="px-3 py-2">Empfänger</th>
<th className="px-3 py-2">Anlass</th>
<th className="px-3 py-2">Fällig</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((rechnung) => {
const offen =
rechnung.stand === RechnungStand.VERSENDET ||
rechnung.stand === RechnungStand.GEMAHNT;
const ueberfaellig = offen && new Date(rechnung.faelligAm) < new Date();
return (
<tr key={rechnung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap font-medium">{rechnung.nummer}</td>
<td className="px-3 py-2">
{rechnung.empfaenger}
{rechnung.ort && (
<span className="block text-xs text-[var(--muted-foreground)]">
{rechnung.plz} {rechnung.ort}
</span>
)}
</td>
<td className="px-3 py-2">{ANLASS_TEXT[rechnung.anlass]}</td>
<td
className={`px-3 py-2 whitespace-nowrap ${
ueberfaellig ? "text-[var(--destructive)]" : ""
}`}
>
{alsDatum(rechnung.faelligAm)}
</td>
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
{alsBetrag(rechnung.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={RECHNUNG_STAND_VARIANTE[rechnung.stand]}>
{RECHNUNG_STAND_TEXT[rechnung.stand]}
</Badge>
{rechnung.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{rechnung.mahnstufe}. Mahnung
</span>
)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{rechnung.pdfDateiId && (
<Button size="sm" variant="ghost" asChild>
<a href={`/api/dateien/${rechnung.pdfDateiId}`} target="_blank">
<Download aria-hidden />
<span className="sr-only">PDF herunterladen</span>
</a>
</Button>
)}
{darfBearbeiten && rechnung.stand === RechnungStand.ENTWURF && (
<Button
size="sm"
variant="ghost"
onClick={() => versenden.mutate({ id: rechnung.id })}
disabled={versenden.isPending}
>
<Send aria-hidden />
Versenden
</Button>
)}
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: rechnung.id,
nummer: rechnung.nummer,
betrag: String(rechnung.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => mahnen.mutate({ ids: [rechnung.id] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
Mahnen
</Button>
</>
)}
{darfBearbeiten && rechnung.stand !== RechnungStand.STORNIERT && (
<>
{rechnung.stand !== RechnungStand.ENTWURF && !rechnung.pdfDateiId && (
<Button
size="sm"
variant="ghost"
onClick={() => pdfErzeugen.mutate({ id: rechnung.id })}
>
<FileText aria-hidden />
<span className="sr-only">PDF erzeugen</span>
</Button>
)}
{rechnung.stand !== RechnungStand.BEZAHLT && (
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird storniert? Der Grund steht später in der Bemerkung — die Rechnungsnummer bleibt vergeben.",
);
if (grund?.trim()) {
stornieren.mutate({ id: rechnung.id, grund: grund.trim() });
}
}}
>
<X aria-hidden />
<span className="sr-only">Stornieren</span>
</Button>
)}
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<RechnungAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(nummer) => {
setAnlegenOffen(false);
setHinweis(`Rechnung ${nummer} als Entwurf angelegt. Sie ist noch änderbar.`);
void werkzeuge.rechnung.invalidate();
}}
/>
<BezahltDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.rechnung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
}) {
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/**
* Die Rechnungsmaske.
*
* Der Gesamtbetrag wird nicht eingegeben, sondern aus den Positionen
* gerechnet — hier zur Anzeige, auf dem Server noch einmal verbindlich. Ein
* eingetippter Betrag, der nicht zur Summe der Positionen passt, ist der
* Fehler, den niemand bemerkt, bis der Empfänger anruft.
*/
function RechnungAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (nummer: string) => void;
fehler: (nachricht: string) => void;
}) {
const heute = new Date();
const inVierWochen = new Date(heute.getTime() + 28 * 24 * 60 * 60 * 1000);
const [anlass, setAnlass] = React.useState<RechnungsAnlass>(RechnungsAnlass.FEHLALARM);
const [empfaenger, setEmpfaenger] = React.useState("");
const [zusatz, setZusatz] = React.useState("");
const [strasse, setStrasse] = React.useState("");
const [plz, setPlz] = React.useState("");
const [ort, setOrt] = React.useState("");
const [datum, setDatum] = React.useState(heute.toISOString().slice(0, 10));
const [faellig, setFaellig] = React.useState(inVierWochen.toISOString().slice(0, 10));
const [bemerkung, setBemerkung] = React.useState("");
const [positionen, setPositionen] = React.useState<Position[]>([{ ...LEERE_POSITION }]);
const anlegen = trpc.rechnung.anlegen.useMutation({
onSuccess: (rechnung) => {
setEmpfaenger("");
setZusatz("");
setStrasse("");
setPlz("");
setOrt("");
setBemerkung("");
setPositionen([{ ...LEERE_POSITION }]);
fertig(rechnung.nummer);
},
onError: (aufruf) => fehler(aufruf.message),
});
function aendern(stelle: number, feld: keyof Position, wert: string) {
setPositionen((vorher) =>
vorher.map((position, index) => (index === stelle ? { ...position, [feld]: wert } : position)),
);
}
const summe = positionen.reduce(
(gesamt, position) => gesamt + Number(position.menge || 0) * Number(position.einzelpreis || 0),
0,
);
const vollstaendig =
empfaenger.trim().length > 0 &&
positionen.every(
(position) => position.bezeichnung.trim().length > 0 && Number(position.einzelpreis) >= 0,
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Rechnung schreiben</DialogTitle>
<DialogDescription>
Die Rechnungsnummer vergibt FLORIAN fortlaufend je Jahr — lückenlos, weil eine Lücke
in der Kassenprüfung erklärt werden muss.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-anlass">Anlass</Label>
<Select value={anlass} onValueChange={(wert) => setAnlass(wert as RechnungsAnlass)}>
<SelectTrigger id="rechnung-anlass">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RechnungsAnlass).map((wert) => (
<SelectItem key={wert} value={wert}>
{ANLASS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-empfaenger">Empfänger</Label>
<Input
id="rechnung-empfaenger"
value={empfaenger}
onChange={(ereignis) => setEmpfaenger(ereignis.target.value)}
placeholder="Firma Muster GmbH"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-zusatz">Zusatz</Label>
<Input
id="rechnung-zusatz"
value={zusatz}
onChange={(ereignis) => setZusatz(ereignis.target.value)}
placeholder="z. Hd. Herrn Meier"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-strasse">Straße</Label>
<Input
id="rechnung-strasse"
value={strasse}
onChange={(ereignis) => setStrasse(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-plz">PLZ</Label>
<Input
id="rechnung-plz"
value={plz}
onChange={(ereignis) => setPlz(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-ort">Ort</Label>
<Input
id="rechnung-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-datum">Rechnungsdatum</Label>
<Input
id="rechnung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-faellig">Zahlbar bis</Label>
<Input
id="rechnung-faellig"
type="date"
value={faellig}
onChange={(ereignis) => setFaellig(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Positionen</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPositionen((vorher) => [...vorher, { ...LEERE_POSITION }])}
>
<Plus aria-hidden />
Position
</Button>
</div>
<div className="space-y-2">
{positionen.map((position, stelle) => (
<div key={stelle} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-bezeichnung`}>
Bezeichnung
</Label>
<Input
id={`position-${stelle}-bezeichnung`}
value={position.bezeichnung}
onChange={(ereignis) => aendern(stelle, "bezeichnung", ereignis.target.value)}
placeholder="Einsatz LF 20, angefangene Stunde"
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-menge`}>
Menge
</Label>
<Input
id={`position-${stelle}-menge`}
type="number"
step="0.01"
min="0.01"
value={position.menge}
onChange={(ereignis) => aendern(stelle, "menge", ereignis.target.value)}
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-einheit`}>
Einheit
</Label>
<Input
id={`position-${stelle}-einheit`}
value={position.einheit}
onChange={(ereignis) => aendern(stelle, "einheit", ereignis.target.value)}
placeholder="Std."
/>
</div>
<div className="w-28 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-preis`}>
Einzelpreis
</Label>
<Input
id={`position-${stelle}-preis`}
type="number"
step="0.01"
min="0"
value={position.einzelpreis}
onChange={(ereignis) => aendern(stelle, "einzelpreis", ereignis.target.value)}
/>
</div>
<Button
type="button"
size="icon"
variant="ghost"
disabled={positionen.length === 1}
onClick={() =>
setPositionen((vorher) => vorher.filter((_, index) => index !== stelle))
}
>
<Trash2 aria-hidden />
<span className="sr-only">Position entfernen</span>
</Button>
</div>
))}
</div>
<p className="text-right text-sm">
Summe <strong className="tabular-nums">{alsBetrag(summe.toFixed(2))}</strong>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bemerkung">Bemerkung</Label>
<Textarea
id="rechnung-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Einsatz vom 14.03. — Fehlalarm durch Brandmeldeanlage"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
anlass,
empfaenger,
empfaengerZusatz: zusatz || null,
strasse: strasse || null,
plz: plz || null,
ort: ort || null,
datum: new Date(datum),
faelligAm: new Date(faellig),
bemerkung: bemerkung || null,
positionen: positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: Number(position.menge || 1),
einheit: position.einheit || null,
einzelpreis: Number(position.einzelpreis || 0),
})),
})
}
disabled={!vollstaendig || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang zu einer Rechnung — erzeugt die Buchung. */
function BezahltDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; nummer: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const [teilbetrag, setTeilbetrag] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.rechnung.bezahlen.useMutation({
onSuccess: () => {
setTeilbetrag("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechnung {eintrag?.nummer} bezahlt</DialogTitle>
<DialogDescription>
Über {alsBetrag(eintrag?.betrag)}. Es entsteht zugleich die Buchung im Kassenbuch.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-datum">Eingegangen am</Label>
<Input
id="rechnung-bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-betrag">Abweichender Betrag</Label>
<Input
id="rechnung-bezahlt-betrag"
type="number"
step="0.01"
min="0.01"
value={teilbetrag}
onChange={(ereignis) => setTeilbetrag(ereignis.target.value)}
placeholder={eintrag?.betrag}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="rechnung-bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="rechnung-bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
betrag: teilbetrag ? Number(teilbetrag) : undefined,
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+457
View File
@@ -0,0 +1,457 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BanknoteArrowUp,
ClipboardCheck,
FileText,
Landmark,
Plus,
Users,
Wallet,
} from "lucide-react";
import { KontoArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KONTOART_TEXT, alsBetrag, ibanKurz } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Kassenübersicht.
*
* Zuerst die Konten mit ihrem Bestand — die Zahl, wegen der jemand hierher
* kommt. Der Bestand ist **gerechnet**, nicht gespeichert: Anfangsbestand plus
* die Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer
* nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
*
* Darunter die Arbeit, die ansteht. Nicht als Zahlenwand, sondern als vier
* Wege in die vier Bereiche — wer die Kasse führt, hat drei bis fünf Dinge zu
* tun, und die sollen mit einem Klick erreichbar sein.
*/
export function KassenUebersicht({
darfBearbeiten,
darfAbschluss,
}: {
darfBearbeiten: boolean;
darfAbschluss: boolean;
}) {
const werkzeuge = trpc.useUtils();
const jahr = new Date().getFullYear();
const [kontoOffen, setKontoOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery({ mitInaktiven: false });
const kategorien = trpc.buchungskategorie.liste.useQuery();
const offeneBuchungen = trpc.buchung.liste.useQuery({ nurOffene: true, anzahl: 1 });
const beitraege = trpc.beitrag.liste.useQuery({ jahr });
const rechnungen = trpc.rechnung.liste.useQuery({ jahr });
const letzte = trpc.buchung.liste.useQuery({ anzahl: 8 });
const standard = trpc.buchungskategorie.standardAnlegen.useMutation({
onSuccess: () => void werkzeuge.buchungskategorie.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const gesamt = (konten.data ?? []).reduce(
(bisher, konto) => bisher + Number(konto.bestand),
0,
);
const keineKategorien = (kategorien.data?.length ?? 0) === 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="kassenfehler" />}
{/* Der Einstieg für eine Wehr, die gerade anfängt. */}
{keineKategorien && darfBearbeiten && !kategorien.isLoading && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="text-base">Die Kasse ist noch leer</CardTitle>
<CardDescription>
Es fehlen die Kategorien, unter denen gebucht wird. FLORIAN bringt einen
Kontenrahmen mit, wie ihn eine Feuerwehr braucht — er lässt sich danach
erweitern und umbenennen.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => standard.mutate()} disabled={standard.isPending}>
{standard.isPending ? "Wird angelegt …" : "Kontenrahmen anlegen"}
</Button>
</CardContent>
</Card>
)}
{/* Konten */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setKontoOffen(true)}>
<Plus aria-hidden />
Konto anlegen
</Button>
)}
</div>
{konten.isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 3 }).map((_, zeile) => (
<div
key={zeile}
className="h-28 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
aria-hidden
/>
))}
</div>
)}
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Eine Wehr führt meistens drei: die Barkasse im Gerätehaus, ein Bankkonto und eine Rücklage."
/>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{konto.art === KontoArt.BAR ? (
<Wallet className="size-4 text-[var(--muted-foreground)]" aria-hidden />
) : (
<Landmark className="size-4 text-[var(--muted-foreground)]" aria-hidden />
)}
<CardTitle className="text-sm font-medium">{konto.bezeichnung}</CardTitle>
</div>
<CardDescription>
{KONTOART_TEXT[konto.art]}
{konto.iban && ` · ${ibanKurz(konto.iban)}`}
</CardDescription>
</CardHeader>
<CardContent>
<p
className={`text-2xl font-semibold tabular-nums ${
Number(konto.bestand) < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(konto.bestand)}
</p>
</CardContent>
</Card>
))}
{(konten.data?.length ?? 0) > 1 && (
<Card className="border-[var(--primary)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<CardDescription>über alle Konten</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">
{alsBetrag(gesamt.toFixed(2))}
</p>
</CardContent>
</Card>
)}
</div>
</section>
{/* Was ansteht */}
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Aufgabe
titel="Buchungen"
symbol={<BanknoteArrowUp className="size-4" aria-hidden />}
wert={offeneBuchungen.data?.buchungen.length ? "Entwürfe offen" : "alles erfasst"}
hinweis={`Saldo ${alsBetrag(letzte.data?.summe.saldo)}`}
pfad="/kasse/buchungen"
hervorheben={Boolean(offeneBuchungen.data?.buchungen.length)}
/>
<Aufgabe
titel={`Beiträge ${jahr}`}
symbol={<Users className="size-4" aria-hidden />}
wert={
beitraege.data
? `${beitraege.data.kennzahlen.anzahlOffen} offen`
: "—"
}
hinweis={beitraege.data ? `${alsBetrag(beitraege.data.kennzahlen.offen)} ausstehend` : ""}
pfad="/kasse/beitraege"
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Aufgabe
titel="Rechnungen"
symbol={<FileText className="size-4" aria-hidden />}
wert={rechnungen.data ? `${rechnungen.data.kennzahlen.anzahlOffen} offen` : "—"}
hinweis={
rechnungen.data?.kennzahlen.ueberfaellig
? `${rechnungen.data.kennzahlen.ueberfaellig} überfällig`
: rechnungen.data
? `${alsBetrag(rechnungen.data.kennzahlen.offen)} ausstehend`
: ""
}
pfad="/kasse/rechnungen"
hervorheben={(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0}
/>
<Aufgabe
titel="Abschluss"
symbol={<ClipboardCheck className="size-4" aria-hidden />}
wert={darfAbschluss ? "Kassenprüfung" : "nur ansehen"}
hinweis="Bestände zählen und festschreiben"
pfad="/kasse/abschluss"
/>
</section>
{/* Die letzten Buchungen */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Zuletzt gebucht</h2>
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse/buchungen">Alle Buchungen</Link>
</Button>
</div>
{(letzte.data?.buchungen.length ?? 0) === 0 && !letzte.isLoading ? (
<NichtsGefunden
titel="Noch nichts gebucht"
hinweis="Die erste Buchung entsteht unter „Buchungen“."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{letzte.data?.buchungen.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
{buchung.verwendungszweck}
{buchung.storniertVonId && (
<Badge variant="secondary" className="ml-2">
gegengebucht
</Badge>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === "AUSGABE" ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === "AUSGABE" ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={() => {
setKontoOffen(false);
void werkzeuge.kassenkonto.invalidate();
}}
/>
)}
</div>
);
}
function Aufgabe({
titel,
symbol,
wert,
hinweis,
pfad,
hervorheben = false,
}: {
titel: string;
symbol: React.ReactNode;
wert: string;
hinweis: string;
pfad: string;
hervorheben?: boolean;
}) {
return (
<Link href={pfad} className="block">
<Card
className={`h-full transition-colors hover:bg-[var(--accent)] ${
hervorheben ? "border-[var(--pruefung-faellig)]" : ""
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
{symbol}
<CardTitle className="text-sm font-medium">{titel}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="font-medium">{wert}</p>
<p className="text-sm text-[var(--muted-foreground)]">{hinweis}</p>
</CardContent>
</Card>
</Link>
);
}
/** Ein Konto anlegen. */
function KontoAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [art, setArt] = React.useState<KontoArt>(KontoArt.BAR);
const [iban, setIban] = React.useState("");
const [anfangsbestand, setAnfangsbestand] = React.useState("0");
const anlegen = trpc.kassenkonto.anlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setIban("");
setAnfangsbestand("0");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Er lässt sich später nicht
mehr ändern, sobald darauf gebucht wurde — eine Korrektur läuft dann über eine
Buchung.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-bezeichnung">Bezeichnung</Label>
<Input
id="konto-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Barkasse Gerätehaus"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="konto-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as KontoArt)}>
<SelectTrigger id="konto-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(KontoArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KONTOART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="konto-bestand">Anfangsbestand</Label>
<Input
id="konto-bestand"
type="number"
step="0.01"
value={anfangsbestand}
onChange={(ereignis) => setAnfangsbestand(ereignis.target.value)}
/>
</div>
</div>
{art !== KontoArt.BAR && (
<div className="space-y-2">
<Label htmlFor="konto-iban">IBAN</Label>
<Input
id="konto-iban"
value={iban}
onChange={(ereignis) => setIban(ereignis.target.value.toUpperCase())}
placeholder="DE00 0000 0000 0000 0000 00"
className="font-mono"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht auf der Rechnung im Zahlungshinweis. In Listen wird sie gekürzt angezeigt.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
art,
iban: iban.replace(/\s/g, "") || null,
anfangsbestand: Number(anfangsbestand) || 0,
aktiv: true,
})
}
disabled={!bezeichnung.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+3
View File
@@ -3,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
import { fuerOrganisation } from "@/lib/db";
import { rechteVon } from "@/lib/authz";
import { Seitenleiste } from "@/components/layout/seitenleiste";
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
import { ThemeToggle } from "@/components/layout/theme-toggle";
import { TrpcProvider } from "@/lib/trpc/provider";
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
rollen={sitzung.rollen}
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
personName={sitzung.name}
istVerwalter={sitzung.istVerwalter ?? false}
/>
<div className="md:pl-64">
<AufsichtHinweis />
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
<ThemeToggle />
</header>
+47 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { darfPersonSehen } from "@/lib/authz";
import { fuerOrganisation } from "@/lib/db";
import { alsDatum, personAnrede } from "@/lib/utils";
@@ -10,6 +11,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
import { Badge } from "@/components/ui/badge";
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
import { Datenauskunft } from "@/components/stammdaten/auskunft";
export const metadata = { title: "Mitglied" };
@@ -48,7 +50,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
eintrittsdatum: true,
austrittsdatum: true,
aktiv: true,
letzteAnmeldung: true,
// Seit Phase 10 hängt die Anmeldung am Konto, nicht an der Akte. Ob
// ein Mitglied überhaupt eines hat, ist die nützlichere Auskunft als
// ein Zeitstempel: ohne Konto kommt es nicht in FLORIAN hinein.
mitgliedschaft: {
select: {
art: true,
benutzer: {
select: { email: true, letzteAnmeldung: true, aktiv: true },
},
},
},
rollen: { include: { rolle: true } },
qualifikationen: {
where: { geloeschtAm: null },
@@ -86,6 +98,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
"Anschrift",
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
],
[
"Zugang",
person.mitgliedschaft?.benutzer ? (
<>
{person.mitgliedschaft.benutzer.email}
<span className="block text-xs text-[var(--muted-foreground)]">
{person.mitgliedschaft.benutzer.aktiv
? person.mitgliedschaft.benutzer.letzteAnmeldung
? `zuletzt angemeldet am ${alsDatum(person.mitgliedschaft.benutzer.letzteAnmeldung)}`
: "noch nie angemeldet"
: "Konto abgeschaltet"}
</span>
</>
) : (
<>
kein Konto
<span className="block text-xs text-[var(--muted-foreground)]">
Diese Person ist geführt, meldet sich aber nicht an.
</span>
</>
),
],
];
/* eslint-enable react/jsx-key */
@@ -98,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
Zurück zur Mitgliederliste
</Link>
</Button>
<div className="flex flex-wrap items-start justify-between gap-3">
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
{/* Die Auskunft nach Art. 15 DSGVO. Sie steht hier und nicht in
einem Datenschutzmenü: gefragt wird sie zu einer bestimmten
Person, und genau deren Seite ist man gerade. */}
{hatRecht(sitzung, "person.export") && (
<Datenauskunft
personId={person.id}
personName={`${person.vorname} ${person.nachname}`}
/>
)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
{person.rollen.map((zuweisung) => (
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
+6
View File
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
redirect("/pruefungen");
}
// Eine Prüfung braucht einen Prüfer mit Namen und Akte — sie ist der
// Nachweis gegenüber der Unfallkasse. Das Recht oben schließt die
// Kreisaufsicht bereits aus; die Zusicherung steht hier trotzdem, weil
// sich Rechte verschieben lassen und ein Protokoll ohne Prüfer wertlos ist.
if (!sitzung.personId) redirect("/pruefungen");
const { geraetId, terminId, vorlageId } = await searchParams;
if (!geraetId) redirect("/pruefungen");
+19
View File
@@ -0,0 +1,19 @@
"use server";
import { sitzungNeuAusstellen } from "@/lib/auth";
/**
* In eine andere Wehr wechseln.
*
* Eine Server Action und keine tRPC-Mutation: der Wechsel ändert das
* Sitzungstoken im Cookie, und das kann nur die Anmeldeschicht. tRPC läuft
* über dieselbe Anfrage, hat aber keinen Zugriff auf das Setzen des Cookies.
*
* Übergeben wird die Kennung der **Mitgliedschaft**, nicht die der Wehr. Das
* ist der Unterschied zwischen einem Wunsch und einer Anweisung: eine fremde
* Wehr zu nennen bringt nichts, weil es dazu keine Mitgliedschaft dieses
* Kontos gibt. Geprüft wird das serverseitig im `jwt`-Rückruf, nicht hier.
*/
export async function wehrWechseln(mitgliedschaftId: string): Promise<void> {
await sitzungNeuAusstellen({ user: { mitgliedschaftId } });
}
+31
View File
@@ -0,0 +1,31 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { Wehrverwaltung } from "@/app/(app)/wehren/verwaltung";
export const metadata = { title: "Wehren und Konten" };
/**
* Die Verwaltung der Installation.
*
* Der einzige Bereich, der über Mandantengrenzen hinweg arbeitet — und
* deshalb der einzige, dessen Zugang nicht an einem Recht innerhalb einer Wehr
* hängt, sondern am Konto selbst.
*/
export default async function WehrenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!sitzung.istVerwalter) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Wehren und Konten</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Wehren dieser Installation, die Anmeldekonten und wer zu welcher Wehr gehört.
</p>
</div>
<Wehrverwaltung />
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More