Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b20abcb23 | ||
|
|
14cc4de9b8 | ||
|
|
91742a0dd7 | ||
|
|
2b53556175 |
@@ -164,6 +164,11 @@ AUTH_LOCAL_ENABLED=true
|
|||||||
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
# Nach dem ersten Login das Passwort in der Oberfläche ändern.
|
||||||
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
SEED_ADMIN_EMAIL=admin@beispielwehr.de
|
||||||
SEED_ADMIN_PASSWORT=Feuerwehr2026
|
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.
|
# bcrypt-Kostenfaktor. 12 ist ein guter Kompromiss aus Sicherheit und Tempo.
|
||||||
AUTH_BCRYPT_ROUNDS=12
|
AUTH_BCRYPT_ROUNDS=12
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,3 +40,6 @@ debug.log
|
|||||||
# Prisma
|
# Prisma
|
||||||
prisma/*.db
|
prisma/*.db
|
||||||
prisma/*.db-journal
|
prisma/*.db-journal
|
||||||
|
|
||||||
|
# Sicherungen bleiben lokal — sie enthalten den gesamten Bestand.
|
||||||
|
sicherungen/
|
||||||
|
|||||||
@@ -6,11 +6,464 @@ Alle nennenswerten Änderungen an FLORIAN. Format nach
|
|||||||
|
|
||||||
Die Regeln dazu stehen in [docs/VERSIONIERUNG.md](docs/VERSIONIERUNG.md).
|
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
|
Aufräumarbeiten vor Phase 9. Drei Punkte, die sich über mehrere Phasen
|
||||||
angesammelt hatten und in jeder Phase Zeit gekostet haben.
|
angesammelt hatten und in jeder Phase Zeit gekostet haben.
|
||||||
|
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
|
||||||
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
|
- **ESLint** hat endlich eine Konfiguration (`eslint.config.mjs`, Flat Config).
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
# FLORIAN — Feuerwehr-Verwaltungsplattform
|
# FLORIAN — Feuerwehr-Verwaltungsplattform
|
||||||
|
|
||||||
|
**Version 1.0.0** — freigegeben für den Echtbetrieb.
|
||||||
|
|
||||||
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
|
Selbstgehostete Verwaltungssoftware für Freiwillige Feuerwehren. Läuft im
|
||||||
Gerätehaus, nicht in der Cloud. Keine Telemetrie, keine externen CDNs.
|
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
|
## Schnellstart
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -32,7 +38,9 @@ Anmelden mit `SEED_ADMIN_EMAIL` und `SEED_ADMIN_PASSWORT` aus der `.env`
|
|||||||
| Phase 6 — Kartenmodule | fertig |
|
| Phase 6 — Kartenmodule | fertig |
|
||||||
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
| Phase 7 — Benachrichtigungen und Termine | fertig |
|
||||||
| Phase 8 — Lokale KI | 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
|
Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
||||||
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
[docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md).
|
||||||
@@ -41,7 +49,8 @@ Vollständige Planung mit Aufwänden: [docs/ROADMAP.md](docs/ROADMAP.md) und
|
|||||||
|
|
||||||
| Datei | Inhalt |
|
| 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/ROADMAP.md](docs/ROADMAP.md) | Phasenplan und was jeweils entsteht |
|
||||||
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
|
| [docs/AUFWANDSSCHAETZUNG.md](docs/AUFWANDSSCHAETZUNG.md) | Aufwand je Phase, Schätzung gegen Ist |
|
||||||
| [docs/DEVLOG.md](docs/DEVLOG.md) | Entwicklungstagebuch mit Zeiterfassung |
|
| [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
|
## Befehle
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run sicherung # Datenbank und Dateien sichern
|
||||||
|
npm run wiederherstellen # zurueckspielen (Probelauf ohne --wirklich)
|
||||||
npm run dev # Entwicklungsserver auf Port 3100
|
npm run dev # Entwicklungsserver auf Port 3100
|
||||||
npm run build # Produktionsbuild
|
npm run build # Produktionsbuild
|
||||||
npm run worker:dev # Hintergrundprozess mit Neuladen
|
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
|
Der App-Container wendet beim Start ausstehende Migrationen an; der Worker
|
||||||
tut das bewusst nicht, damit nicht zwei Prozesse gleichzeitig migrieren.
|
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
|
## Datenschutz
|
||||||
|
|
||||||
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
|
- Keine Telemetrie, keine externen CDNs, keine Google Fonts. Es werden nur
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
# Anleitung
|
# Anleitung
|
||||||
|
|
||||||
Bedienung von FLORIAN, Stand Phase 8 (Version 0.9.0). Die Bilder zeigen die
|
Bedienung von FLORIAN, Version 1.0.0. Die Bilder zeigen die Beispielwehr aus
|
||||||
Beispielwehr aus dem Seed und werden mit `npm run screenshots` neu erzeugt.
|
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
|
Ohne Vision-Modell funktioniert alles bis auf das Auslesen: der Beleg wird
|
||||||
fotografiert und abgelegt, die Angaben tragen Sie von Hand ein.
|
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
|
Ist der Beleg gegengezeichnet, steht in der Liste der erledigten Belege ein
|
||||||
sauber erfasster Beleg mit Foto — und der ist schon mehr wert als ein
|
Knopf **Buchen**. Er führt in einem Schritt ins Kassenbuch — zu wählen sind
|
||||||
Schuhkarton.
|
nur Konto und Kategorie. Siehe [Kapitel 48](#48-buchen).
|
||||||
|
|
||||||
### Das KI-Protokoll
|
### 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.
|
||||||
|
|
||||||
|

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

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

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

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

|
||||||
|
|
||||||
|
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
|
Neun Rollen, jede mit einem festen Satz Rechte. Die Zuordnung steht zentral in
|
||||||
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
`src/lib/authz.ts` und wird beim Seed in die Datenbank gespiegelt — eine Wehr
|
||||||
@@ -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 |
|
| **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 |
|
| **Gerätewart** | Fahrzeuge und Geräte anlegen, ändern, aussondern; Prüfungen planen und durchführen |
|
||||||
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
|
| **Atemschutzgerätewart** | zusätzlich Atemschutzgeräte, Träger und die Überwachung nach FwDV 7 |
|
||||||
| **Kassier** | Buchungen und Kassenabschluss (Modul 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 |
|
| **Schriftführer** | Mitglieder pflegen, Einsätze und Berichte erfassen, den Terminkalender führen |
|
||||||
| **Ausbilder** | Übungen und Übungstermine planen, Teilnahme, Anwesenheit und Qualifikationen erfassen |
|
| **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 |
|
| **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 |
|
| **Terminal** | nur anmelden und erfassen — nie verwalten |
|
||||||
|
|
||||||
Zwei Dinge sind bewusst so gebaut:
|
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.
|
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,
|
Wie sich am Terminal angemeldet wird, steht in Kapitel 24. Hier geht es darum,
|
||||||
was dahinter liegt.
|
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,
|
Jede schreibende Änderung erzeugt einen Eintrag mit Zeitpunkt, Person,
|
||||||
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
betroffenem Datensatz und den geänderten Feldern im Vorher-Nachher-Vergleich.
|
||||||
@@ -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 |
|
| Beobachtung | Wahrscheinliche Ursache | Was hilft |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -39,8 +39,10 @@ Faktoren, die in die Schätzung eingehen:
|
|||||||
| 6 | Kartenmodule | 14–18 PT | 3,5–4,5 h | **2:50 h** |
|
| 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** |
|
| 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** |
|
| 8 | Lokale KI | 9–12 PT | 2,5–3 h | **3:40 h** |
|
||||||
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | offen |
|
| 9 | Kassenverwaltung | 8–11 PT | 2–3 h | **4:10 h** |
|
||||||
| | **Summe** | **123–164 PT** | **28–37 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
|
123–164 Personentage entsprechen etwa **6 bis 8 Monaten Vollzeitarbeit** einer
|
||||||
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
einzelnen Person. Zum Vergleich: eine kommerzielle Feuerwehrverwaltung kostet
|
||||||
@@ -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
|
das Muster steht seit Phase 0 mit dem Audit-Log-Trigger und lässt sich
|
||||||
übernehmen.
|
ü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
|
## Was hier nicht drin ist
|
||||||
|
|||||||
@@ -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 7 — Benachrichtigungen und Termine | 3:10 h | 1 | 04.09.2026 |
|
||||||
| Phase 8 — Lokale KI | 3:40 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 |
|
| 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|

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

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

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

|
||||||
|
|
||||||
|
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** 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. Untersuchungsdatum,
|
||||||
|
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.
|
||||||
|
|
||||||
|

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

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

|
||||||
|
|
||||||
|
### 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.*
|
||||||
@@ -18,8 +18,10 @@ Phase 5 ████████████████████ fertig
|
|||||||
Phase 6 ████████████████████ fertig 0.7.0
|
Phase 6 ████████████████████ fertig 0.7.0
|
||||||
Phase 7 ████████████████████ fertig 0.8.0
|
Phase 7 ████████████████████ fertig 0.8.0
|
||||||
Phase 8 ████████████████████ fertig 0.9.0
|
Phase 8 ████████████████████ fertig 0.9.0
|
||||||
Phase 9 ░░░░░░░░░░░░░░░░░░░░ als Nächstes 0.10.0
|
Phase 9 ████████████████████ fertig 0.10.0
|
||||||
Freigabe ░░░░░░░░░░░░░░░░░░░░ offen 1.0.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
|
schreiben, Wortsuche, Belege ablegen, beide Protokolle. Wo etwas fehlt, steht
|
||||||
ein Satz, der sagt was fehlt und wie es ohne geht.
|
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
|
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`.
|
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
|
kein Datum daraus mehr an. Beträge durchgängig als `DECIMAL(12,2)`, nie als
|
||||||
Fließkommazahl.
|
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.
|
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
|
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
|
**Damit das nicht zur Formalie wird, hier der Stand jeder Voraussetzung — auch
|
||||||
- Sicherungskonzept steht und ist einmal wiederhergestellt worden
|
der offenen.** Eine Freigabe, die alles abhakt, was ohnehin nicht geprüft
|
||||||
- Schulung von Kommandant, Gerätewart und Atemschutzgerätewart erfolgt
|
wurde, ist keine.
|
||||||
- Löschkonzept nach DSGVO abgenommen
|
|
||||||
- Der Hinweis zur analogen Atemschutzüberwachung wird bewusst und
|
| Voraussetzung | Stand |
|
||||||
dokumentiert abgeschaltet — oder bewusst stehen gelassen
|
|---|---|
|
||||||
|
| 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 |
|
| 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. |
|
| `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. |
|
| 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
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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
|
Die Mandantenfähigkeit ist mit **Phase 10** umgesetzt: umschalten, Kreisebene,
|
||||||
trägt: das Datenmodell und RLS können bereits mehrere Wehren. Was fehlt, ist
|
Doppelmitgliedschaft, Verwaltung. Offen geblieben sind bewusst die
|
||||||
eine Oberfläche zum Umschalten und ein Konzept für gemeindeübergreifende
|
gemeindeübergreifenden Auswertungen — dafür braucht es zuerst eine abgestimmte
|
||||||
Auswertungen. Für einen Landkreis, der mehrere Wehren betreiben will, wäre das
|
Definition dessen, was verglichen werden soll.
|
||||||
der nächste sinnvolle Schritt.
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 224 KiB After Width: | Height: | Size: 224 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 402 KiB After Width: | Height: | Size: 402 KiB |
|
Before Width: | Height: | Size: 413 KiB After Width: | Height: | Size: 413 KiB |
|
Before Width: | Height: | Size: 179 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 180 KiB After Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 195 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 745 KiB After Width: | Height: | Size: 803 KiB |
|
Before Width: | Height: | Size: 764 KiB After Width: | Height: | Size: 824 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 275 KiB After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 376 KiB After Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 789 KiB |
|
Before Width: | Height: | Size: 742 KiB After Width: | Height: | Size: 807 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 250 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 193 KiB After Width: | Height: | Size: 193 KiB |
|
Before Width: | Height: | Size: 192 KiB After Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 184 KiB After Width: | Height: | Size: 187 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 175 KiB After Width: | Height: | Size: 175 KiB |
|
Before Width: | Height: | Size: 176 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 260 KiB After Width: | Height: | Size: 400 KiB |
|
Before Width: | Height: | Size: 317 KiB After Width: | Height: | Size: 317 KiB |
|
Before Width: | Height: | Size: 268 KiB After Width: | Height: | Size: 326 KiB |
|
Before Width: | Height: | Size: 270 KiB After Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 203 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 156 KiB After Width: | Height: | Size: 176 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 628 KiB |
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 639 KiB |
|
Before Width: | Height: | Size: 242 KiB After Width: | Height: | Size: 269 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 199 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 200 KiB After Width: | Height: | Size: 200 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 171 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 937 KiB |
|
After Width: | Height: | Size: 959 KiB |
|
After Width: | Height: | Size: 408 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 200 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 352 KiB |
|
After Width: | Height: | Size: 359 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "florian",
|
"name": "florian",
|
||||||
"version": "0.9.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
"description": "FLORIAN — Feuerwehr-Verwaltungsplattform",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -23,6 +23,8 @@
|
|||||||
"db:migrate:dev": "prisma migrate dev",
|
"db:migrate:dev": "prisma migrate dev",
|
||||||
"db:push": "prisma db push",
|
"db:push": "prisma db push",
|
||||||
"db:seed": "tsx prisma/seed.ts",
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"sicherung": "tsx scripts/sicherung.ts",
|
||||||
|
"wiederherstellen": "tsx scripts/wiederherstellen.ts",
|
||||||
"db:test-reset": "tsx scripts/testdaten.ts",
|
"db:test-reset": "tsx scripts/testdaten.ts",
|
||||||
"db:studio": "prisma studio",
|
"db:studio": "prisma studio",
|
||||||
"screenshots": "tsx scripts/screenshots.ts",
|
"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();
|
||||||
@@ -107,6 +107,21 @@ enum FahrzeugArt {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Dienstgrad nach bayerischer Feuerwehr-Dienstgradverordnung (Auswahl).
|
/// 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 {
|
enum Dienstgrad {
|
||||||
FEUERWEHRANWAERTER
|
FEUERWEHRANWAERTER
|
||||||
FEUERWEHRMANN
|
FEUERWEHRMANN
|
||||||
@@ -153,6 +168,7 @@ model Organisation {
|
|||||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
personen Person[]
|
personen Person[]
|
||||||
|
mitgliedschaften Mitgliedschaft[]
|
||||||
rollen Rolle[]
|
rollen Rolle[]
|
||||||
personRollen PersonRolle[]
|
personRollen PersonRolle[]
|
||||||
rolleRechte RolleBerechtigung[]
|
rolleRechte RolleBerechtigung[]
|
||||||
@@ -234,6 +250,17 @@ model Organisation {
|
|||||||
diktate Diktat[]
|
diktate Diktat[]
|
||||||
belegentwuerfe Belegentwurf[]
|
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])
|
@@index([aktiv])
|
||||||
@@map("organisation")
|
@@map("organisation")
|
||||||
}
|
}
|
||||||
@@ -269,17 +296,12 @@ model Person {
|
|||||||
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
|
/// Aktiver Dienst, passiv, Jugendfeuerwehr, Ehrenmitglied.
|
||||||
abteilung String? @db.VarChar(60)
|
abteilung String? @db.VarChar(60)
|
||||||
|
|
||||||
/// bcrypt-Hash für den lokalen Login. Null, wenn nur OIDC genutzt wird.
|
/// Die Anmeldedaten liegen seit Phase 10 am `Benutzer`, nicht mehr hier.
|
||||||
passwortHash String? @db.VarChar(120)
|
///
|
||||||
passwortGeaendertAm DateTime? @db.Timestamptz(3)
|
/// Der Grund ist die Doppelmitgliedschaft: wer in zwei Wehren geführt wird,
|
||||||
/// "sub"-Claim aus authentik. Verknüpft die Person mit dem OIDC-Konto.
|
/// hat dort zwei Akten — aber ein Passwort. Lägen Hash und OIDC-Subject
|
||||||
oidcSubject String? @db.VarChar(255)
|
/// weiterhin an der Akte, gäbe es zwei davon, die auseinanderlaufen können,
|
||||||
/// Gruppen aus dem letzten OIDC-Login — nur zur Nachvollziehbarkeit.
|
/// und die Anmeldung per E-Mail wäre mehrdeutig.
|
||||||
oidcGruppen String[] @default([])
|
|
||||||
|
|
||||||
letzteAnmeldung DateTime? @db.Timestamptz(3)
|
|
||||||
fehlversuche Int @default(0)
|
|
||||||
gesperrtBis DateTime? @db.Timestamptz(3)
|
|
||||||
aktiv Boolean @default(true)
|
aktiv Boolean @default(true)
|
||||||
|
|
||||||
/// Profilbild. Liegt wie alles andere in MinIO.
|
/// Profilbild. Liegt wie alles andere in MinIO.
|
||||||
@@ -290,6 +312,7 @@ model Person {
|
|||||||
geloeschtAm DateTime? @db.Timestamptz(3)
|
geloeschtAm DateTime? @db.Timestamptz(3)
|
||||||
|
|
||||||
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
organisation Organisation @relation(fields: [organisationId], references: [id], onDelete: Cascade)
|
||||||
|
mitgliedschaft Mitgliedschaft? @relation("MitgliedschaftPerson")
|
||||||
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
|
avatar Datei? @relation("PersonAvatar", fields: [avatarDateiId], references: [id], onDelete: SetNull)
|
||||||
rollen PersonRolle[] @relation("PersonRollenInhaber")
|
rollen PersonRolle[] @relation("PersonRollenInhaber")
|
||||||
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
|
vergebeneRollen PersonRolle[] @relation("PersonRollenVergeber")
|
||||||
@@ -356,19 +379,112 @@ model Person {
|
|||||||
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
|
erfassteBelege Belegentwurf[] @relation("BelegentwurfErfasser")
|
||||||
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
|
bestaetigteBelege Belegentwurf[] @relation("BelegentwurfBestaetiger")
|
||||||
|
|
||||||
/// E-Mail und OIDC-Subject sind je Wehr eindeutig, nicht global — dieselbe
|
// Phase 9 — Kassenverwaltung
|
||||||
/// Person kann in zwei Wehren Mitglied sein.
|
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, email])
|
||||||
@@unique([organisationId, oidcSubject])
|
|
||||||
@@unique([organisationId, mitgliedsnummer])
|
@@unique([organisationId, mitgliedsnummer])
|
||||||
@@index([organisationId, nachname, vorname])
|
@@index([organisationId, nachname, vorname])
|
||||||
@@index([organisationId, aktiv])
|
@@index([organisationId, aktiv])
|
||||||
@@index([oidcSubject])
|
|
||||||
@@map("person")
|
@@map("person")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
|
/// Eine Rolle innerhalb einer Wehr. Die Systemrollen legt der Seed an; eigene
|
||||||
/// Rollen kann der Kommandant ergänzen.
|
/// 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 {
|
model Rolle {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
organisationId String @db.Uuid
|
organisationId String @db.Uuid
|
||||||
@@ -602,6 +718,11 @@ model Datei {
|
|||||||
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
|
alsDiktataufnahme Diktat[] @relation("DiktatAufnahme")
|
||||||
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
|
alsBelegfoto Belegentwurf[] @relation("BelegentwurfFoto")
|
||||||
|
|
||||||
|
// Phase 9
|
||||||
|
alsBuchungsbeleg Buchung[] @relation("BuchungBeleg")
|
||||||
|
alsRechnungsPdf Rechnung[] @relation("RechnungPdf")
|
||||||
|
alsKassenpruefbericht Kassenabschluss[] @relation("AbschlussBericht")
|
||||||
|
|
||||||
@@unique([bucket, objektSchluessel])
|
@@unique([bucket, objektSchluessel])
|
||||||
@@index([organisationId, entitaet, entitaetId])
|
@@index([organisationId, entitaet, entitaetId])
|
||||||
@@index([organisationId, kategorie])
|
@@index([organisationId, kategorie])
|
||||||
@@ -3601,7 +3722,11 @@ model Belegentwurf {
|
|||||||
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
geaendertAm DateTime @updatedAt @db.Timestamptz(3)
|
||||||
geloeschtAm DateTime? @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)
|
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)
|
fotoDatei Datei @relation("BelegentwurfFoto", fields: [fotoDateiId], references: [id], onDelete: Restrict)
|
||||||
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
|
kiVorgang KiVorgang? @relation(fields: [kiVorgangId], references: [id], onDelete: SetNull)
|
||||||
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
|
erfasstVon Person? @relation("BelegentwurfErfasser", fields: [erfasstVonId], references: [id], onDelete: SetNull)
|
||||||
@@ -3611,3 +3736,435 @@ model Belegentwurf {
|
|||||||
@@index([organisationId, belegdatum])
|
@@index([organisationId, belegdatum])
|
||||||
@@map("belegentwurf")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
* Zustellkanäle: niemand wird ungefragt benachrichtigt, auch nicht im Seed.
|
||||||
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
* Ab Phase 8 dazu: vier Wiki-Seiten mit eigenem Text — Prüffristen,
|
||||||
* Atemschutz-Merkblatt, Schlauchpflege und Alarmierungsordnung.
|
* 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,
|
* Das Skript läuft über DIRECT_DATABASE_URL, also mit dem Eigentümer-Zugang,
|
||||||
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
* der Row Level Security umgehen darf. Anders ginge es nicht: der Seed legt
|
||||||
@@ -36,11 +39,14 @@ import {
|
|||||||
Alarmierungsart,
|
Alarmierungsart,
|
||||||
AnwesenheitAnlass,
|
AnwesenheitAnlass,
|
||||||
AusweisTokenTyp,
|
AusweisTokenTyp,
|
||||||
|
BeitragStand,
|
||||||
|
BuchungsArt,
|
||||||
ChecklistenpunktTyp,
|
ChecklistenpunktTyp,
|
||||||
EinsatzFunktion,
|
EinsatzFunktion,
|
||||||
EinsatzKategorie,
|
EinsatzKategorie,
|
||||||
EinsatzStatus,
|
EinsatzStatus,
|
||||||
Dienstgrad,
|
Dienstgrad,
|
||||||
|
KontoArt,
|
||||||
DruckArt,
|
DruckArt,
|
||||||
EreignisArt,
|
EreignisArt,
|
||||||
FahrtZweck,
|
FahrtZweck,
|
||||||
@@ -51,6 +57,7 @@ import {
|
|||||||
LebensdauerBezug,
|
LebensdauerBezug,
|
||||||
LebensdauerKategorie,
|
LebensdauerKategorie,
|
||||||
MangelPrioritaet,
|
MangelPrioritaet,
|
||||||
|
MitgliedschaftArt,
|
||||||
MangelStatus,
|
MangelStatus,
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
PruefErgebnis,
|
PruefErgebnis,
|
||||||
@@ -58,6 +65,8 @@ import {
|
|||||||
PruefungStatus,
|
PruefungStatus,
|
||||||
QualifikationsArt,
|
QualifikationsArt,
|
||||||
RolleSchluessel,
|
RolleSchluessel,
|
||||||
|
RechnungsAnlass,
|
||||||
|
RechnungStand,
|
||||||
SignaturRolle,
|
SignaturRolle,
|
||||||
HydrantStatus,
|
HydrantStatus,
|
||||||
HydrantTyp,
|
HydrantTyp,
|
||||||
@@ -190,7 +199,13 @@ async function rollenAnlegen(organisationId: string, katalog: Map<string, string
|
|||||||
|
|
||||||
const rollenKarte = new 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({
|
const rolle = await prisma.rolle.upsert({
|
||||||
where: {
|
where: {
|
||||||
organisationId_schluessel: {
|
organisationId_schluessel: {
|
||||||
@@ -491,12 +506,31 @@ async function adminAnlegen(organisationId: string, rollenKarte: Map<string, str
|
|||||||
nachname: "Administrator",
|
nachname: "Administrator",
|
||||||
email,
|
email,
|
||||||
dienstgrad: Dienstgrad.KEIN,
|
dienstgrad: Dienstgrad.KEIN,
|
||||||
passwortHash: await bcrypt.hash(passwort, RUNDEN),
|
|
||||||
passwortGeaendertAm: new Date(),
|
|
||||||
abteilung: "Verwaltung",
|
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);
|
const rolleId = rollenKarte.get(RolleSchluessel.SUPERADMIN);
|
||||||
if (rolleId) {
|
if (rolleId) {
|
||||||
await prisma.personRolle.upsert({
|
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
|
// Ablauf
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
@@ -3293,6 +4182,12 @@ async function main(): Promise<void> {
|
|||||||
// Phase 8 — Lokale KI
|
// Phase 8 — Lokale KI
|
||||||
await wikiAnlegen(organisation.id);
|
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("Fertig.");
|
||||||
log("");
|
log("");
|
||||||
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
log(`Wehr: ${ORGANISATION_NAME} (${ORGANISATION_SLUG})`);
|
||||||
|
|||||||
@@ -256,6 +256,46 @@ const AUFNAHMEN: Aufnahme[] = [
|
|||||||
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
|
beschreibung: "Eine Wiki-Seite mit Fundstelle und Verlauf",
|
||||||
angemeldet: true,
|
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; }" })
|
.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||||
.catch(() => undefined);
|
.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 {
|
try {
|
||||||
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
await seite.waitForFunction(() => !document.querySelector(".animate-pulse"), undefined, {
|
||||||
timeout: 15_000,
|
timeout: 15_000,
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 },
|
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.$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 [
|
for (const tabelle of [
|
||||||
"druckmessung",
|
"druckmessung",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -20,6 +20,11 @@ export default async function AnmeldemarkeSeite() {
|
|||||||
const sitzung = await aktuelleSitzung();
|
const sitzung = await aktuelleSitzung();
|
||||||
if (!sitzung) redirect("/anmelden");
|
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 marke = markeErzeugen(sitzung.personId, sitzung.organisationId);
|
||||||
const bild = await qrTextAlsDataUri(marke, 420);
|
const bild = await qrTextAlsDataUri(marke, 420);
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,18 @@ export default async function AusweisSeite() {
|
|||||||
const sitzung = await aktuelleSitzung();
|
const sitzung = await aktuelleSitzung();
|
||||||
if (!sitzung) redirect("/anmelden");
|
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 daten = await fuerOrganisation(sitzung.organisationId, async (db) => {
|
||||||
const person = await db.person.findUnique({
|
const person = await db.person.findUnique({
|
||||||
where: { id: sitzung.personId },
|
where: { id: eigeneAkteId },
|
||||||
select: {
|
select: {
|
||||||
vorname: true,
|
vorname: true,
|
||||||
nachname: true,
|
nachname: true,
|
||||||
@@ -48,7 +57,7 @@ export default async function AusweisSeite() {
|
|||||||
return person;
|
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 adresse = `${env.APP_URL.replace(/\/+$/, "")}/ausweis/${encodeURIComponent(code)}`;
|
||||||
const bild = await qrTextAlsDataUri(adresse, 420);
|
const bild = await qrTextAlsDataUri(adresse, 420);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Image from "next/image";
|
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 { KiStand } from "@prisma/client";
|
||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import { trpc } from "@/lib/trpc/client";
|
import { trpc } from "@/lib/trpc/client";
|
||||||
@@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
|
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
|
||||||
import { dateiHochladen } from "@/lib/hochladen";
|
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,
|
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
|
||||||
* die niemand bemerkt.
|
* die niemand bemerkt.
|
||||||
*/
|
*/
|
||||||
export function BelegListe() {
|
export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) {
|
||||||
const werkzeuge = trpc.useUtils();
|
const werkzeuge = trpc.useUtils();
|
||||||
|
|
||||||
|
const [buchen, setBuchen] = React.useState<Beleg | null>(null);
|
||||||
|
|
||||||
const [laedtHoch, setLaedtHoch] = React.useState(false);
|
const [laedtHoch, setLaedtHoch] = React.useState(false);
|
||||||
const [meldung, setMeldung] = React.useState<string | null>(null);
|
const [meldung, setMeldung] = React.useState<string | null>(null);
|
||||||
const [hinweis, setHinweis] = 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">Kategorie</th>
|
||||||
<th className="py-2 pr-4 text-right">Betrag</th>
|
<th className="py-2 pr-4 text-right">Betrag</th>
|
||||||
<th className="py-2 pr-4">Stand</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -158,11 +176,23 @@ export function BelegListe() {
|
|||||||
{KI_STAND_TEXT[beleg.stand]}
|
{KI_STAND_TEXT[beleg.stand]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2">
|
<td className="py-2 pr-4">
|
||||||
{beleg.bestaetigtVon
|
{beleg.bestaetigtVon
|
||||||
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
|
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
|
||||||
: "—"}
|
: "—"}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -170,6 +200,19 @@ export function BelegListe() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
|
|||||||
</Card>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ export const metadata = { title: "Belege" };
|
|||||||
/**
|
/**
|
||||||
* Belegerfassung.
|
* Belegerfassung.
|
||||||
*
|
*
|
||||||
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
|
* Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9
|
||||||
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
|
* buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst,
|
||||||
* erfasster Beleg mit Foto.
|
* führt deshalb noch lange nicht die Kasse.
|
||||||
*/
|
*/
|
||||||
export default async function BelegeSeite() {
|
export default async function BelegeSeite() {
|
||||||
const sitzung = await aktuelleSitzung();
|
const sitzung = await aktuelleSitzung();
|
||||||
@@ -22,11 +22,11 @@ export default async function BelegeSeite() {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
|
||||||
<p className="text-sm text-[var(--muted-foreground)]">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BelegListe />
|
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,6 +3,7 @@ import { aktuelleSitzung } from "@/lib/auth";
|
|||||||
import { fuerOrganisation } from "@/lib/db";
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
import { rechteVon } from "@/lib/authz";
|
import { rechteVon } from "@/lib/authz";
|
||||||
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
import { Seitenleiste } from "@/components/layout/seitenleiste";
|
||||||
|
import { AufsichtHinweis } from "@/components/layout/wehrwaehler";
|
||||||
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
import { ThemeToggle } from "@/components/layout/theme-toggle";
|
||||||
import { TrpcProvider } from "@/lib/trpc/provider";
|
import { TrpcProvider } from "@/lib/trpc/provider";
|
||||||
|
|
||||||
@@ -31,9 +32,11 @@ export default async function AngemeldetLayout({
|
|||||||
rollen={sitzung.rollen}
|
rollen={sitzung.rollen}
|
||||||
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
organisationName={organisation?.kurzname ?? organisation?.name ?? "Feuerwehr"}
|
||||||
personName={sitzung.name}
|
personName={sitzung.name}
|
||||||
|
istVerwalter={sitzung.istVerwalter ?? false}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="md:pl-64">
|
<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">
|
<header className="flex h-16 items-center justify-end gap-2 border-b border-[var(--border)] px-6 nicht-drucken">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { aktuelleSitzung } from "@/lib/auth";
|
import { aktuelleSitzung } from "@/lib/auth";
|
||||||
|
import { hatRecht } from "@/lib/authz";
|
||||||
import { darfPersonSehen } from "@/lib/authz";
|
import { darfPersonSehen } from "@/lib/authz";
|
||||||
import { fuerOrganisation } from "@/lib/db";
|
import { fuerOrganisation } from "@/lib/db";
|
||||||
import { alsDatum, personAnrede } from "@/lib/utils";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
import { FristAnzeige, NichtsGefunden } from "@/components/stammdaten/anzeige";
|
||||||
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
import { DIENSTGRAD_TEXT } from "@/lib/stammdaten/texte";
|
||||||
|
import { Datenauskunft } from "@/components/stammdaten/auskunft";
|
||||||
|
|
||||||
export const metadata = { title: "Mitglied" };
|
export const metadata = { title: "Mitglied" };
|
||||||
|
|
||||||
@@ -48,7 +50,17 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
|||||||
eintrittsdatum: true,
|
eintrittsdatum: true,
|
||||||
austrittsdatum: true,
|
austrittsdatum: true,
|
||||||
aktiv: 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 } },
|
rollen: { include: { rolle: true } },
|
||||||
qualifikationen: {
|
qualifikationen: {
|
||||||
where: { geloeschtAm: null },
|
where: { geloeschtAm: null },
|
||||||
@@ -86,6 +98,28 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
|||||||
"Anschrift",
|
"Anschrift",
|
||||||
person.strasse ? `${person.strasse}, ${person.plz ?? ""} ${person.ort ?? ""}`.trim() : "—",
|
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 */
|
/* eslint-enable react/jsx-key */
|
||||||
|
|
||||||
@@ -98,7 +132,19 @@ export default async function MitgliedSeite({ params }: { params: Promise<{ id:
|
|||||||
Zurück zur Mitgliederliste
|
Zurück zur Mitgliederliste
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">{personAnrede(person)}</h1>
|
<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">
|
<div className="mt-1 flex flex-wrap items-center gap-2">
|
||||||
{person.rollen.map((zuweisung) => (
|
{person.rollen.map((zuweisung) => (
|
||||||
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
<Badge key={zuweisung.rolle.schluessel} variant="secondary">
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ export default async function NeuePruefungSeite({
|
|||||||
redirect("/pruefungen");
|
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;
|
const { geraetId, terminId, vorlageId } = await searchParams;
|
||||||
|
|
||||||
if (!geraetId) redirect("/pruefungen");
|
if (!geraetId) redirect("/pruefungen");
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||