BauDoc Bahn: Baustellen- und Dokumentenverwaltung für EIU-Projekte
Erstimport. Fachliche Grundlage ist die EIU-Ablagestruktur des Auftraggebers (2-EIU-Ablagestruktur.xlsx): 1.106 Zeilen, aufbereitet zu 159 Ordnern und 944 Soll-Unterlagen mit Fachbereichsrelevanz, Aufbewahrungsfrist, Medium und Übergabezeitpunkt. Module - Dokumentationsstand: Soll-Ist je Projekt, Fristen aus dem Übergabezeitpunkt und den Projektterminen, Ampel, CSV-Rückexport im Format der Ursprungsdatei - Dokumentenmanagement mit Versionen, Freigabelauf, SHA-256-Prüfsummen - Bautagebuch nach Ril 809.0301, nach Abschluss gesperrt - Meldungen und Mängel (Behinderung, Bedenken, Mangel, AvL, Unfall) - Verträge, Nachträge, Aufmaße, Stundenlohnzettel mit zeilenweiser Anerkennung - Rechnungsprüfung: automatischer Abgleich gegen Vertrag, anerkanntes Aufmaß, bestätigte Stundenzettel und Vorrechnungen; getrennte Bescheinigung sachlich richtig / rechnerisch richtig / Zahlungsfreigabe - Kommunikation mit Projektkanälen, Erwähnungen und E-Mail-Benachrichtigung - Revisionssicheres Protokoll aller Vorgänge Rechte Rollen nach dem Vorgabeprozess Bauüberwachung (PL, BHV, BÜB, FBÜ, kaufmännische Steuerung, Planung, Auftragnehmer, Prüfer, Betreiber), Zuordnung je Projekt. Auftragnehmer sehen ausschließlich Daten der eigenen Firma; diese Schranke hängt allein an der Rolle. Welche Bereiche ihnen überhaupt angezeigt werden, entscheidet der Bauherr je Projekt über Schalter in den Einstellungen. Betrieb Docker Compose mit Anwendung, PostgreSQL und Mailpit. Keine Named Volumes – alle persistenten Daten liegen als Bind-Mount unter data/ im Compose- Verzeichnis, damit ein Backup ein Kopiervorgang bleibt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# Kopieren nach .env und anpassen
|
||||||
|
APP_PORT=3400
|
||||||
|
APP_URL=http://localhost:3400
|
||||||
|
|
||||||
|
POSTGRES_USER=baudoc
|
||||||
|
POSTGRES_PASSWORD=baudoc
|
||||||
|
POSTGRES_DB=baudoc
|
||||||
|
DB_PORT=5433
|
||||||
|
|
||||||
|
# Mindestens 32 Zeichen! Erzeugen z.B. mit: openssl rand -base64 48
|
||||||
|
AUTH_SECRET=bitte-aendern-openssl-rand-base64-48-mindestens-32-zeichen
|
||||||
|
|
||||||
|
# E-Mail-Versand. Standard: der mitgelieferte Mailpit-Container (nur Test!)
|
||||||
|
SMTP_HOST=mail
|
||||||
|
SMTP_PORT=1025
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
SMTP_SECURE=false
|
||||||
|
SMTP_FROM=BauDoc Bahn <noreply@baudoc.local>
|
||||||
|
MAIL_UI_PORT=8025
|
||||||
|
|
||||||
|
MAX_UPLOAD_MB=200
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
# Unterlagen des Auftraggebers – bleiben lokal, gehören nicht ins Repository.
|
||||||
|
# Der daraus erzeugte Katalog liegt aufbereitet in app/prisma/data/.
|
||||||
|
*.xlsx
|
||||||
|
*.xls
|
||||||
|
|
||||||
|
# Zugangsdaten – niemals einchecken
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Persistente Daten des Compose-Stacks (Datenbank, Dokumente, Postfach).
|
||||||
|
# Führender Schrägstrich: nur im Wurzelverzeichnis – app/prisma/data/ mit dem
|
||||||
|
# Ablagestruktur-Katalog gehört ausdrücklich ins Repository.
|
||||||
|
/data/
|
||||||
|
|
||||||
|
# Abhängigkeiten und Build-Ergebnisse
|
||||||
|
app/node_modules/
|
||||||
|
app/.next/
|
||||||
|
app/out/
|
||||||
|
app/next-env.d.ts
|
||||||
|
app/tsconfig.tsbuildinfo
|
||||||
|
|
||||||
|
# Lokaler Dateispeicher beim Betrieb ohne Docker
|
||||||
|
app/storage/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
# BauDoc Bahn
|
||||||
|
|
||||||
|
Webbasierte, mehrbenutzerfähige Baustellen- und Dokumentenverwaltung für
|
||||||
|
Eisenbahninfrastruktur-Projekte (EIU). Fachliche Grundlage ist die **EIU-Ablagestruktur**
|
||||||
|
des Auftraggebers – 159 Ordner und 944 Soll-Unterlagen mit Fachbereichsrelevanz,
|
||||||
|
Aufbewahrungsfrist, Medium und Übergabezeitpunkt.
|
||||||
|
|
||||||
|
> Die Ursprungsdatei `2-EIU-Ablagestruktur.xlsx` ist als Unterlage des Auftraggebers
|
||||||
|
> **nicht Teil dieses Repositories**. Der daraus aufbereitete Katalog liegt in
|
||||||
|
> `app/prisma/data/ablagestruktur.json` und wird beim Start eingespielt.
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# AUTH_SECRET setzen: openssl rand -base64 48
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
| Dienst | Adresse | Zweck |
|
||||||
|
|---|---|---|
|
||||||
|
| Anwendung | http://localhost:3400 | Weboberfläche |
|
||||||
|
| Mailpit | http://localhost:8025 | fängt im Testbetrieb alle E-Mails ab |
|
||||||
|
| PostgreSQL | localhost:5433 | Datenbank |
|
||||||
|
|
||||||
|
Beim ersten Start werden Schema, Ablagestruktur-Katalog und ein vollständig
|
||||||
|
durchgespieltes Demo-Projekt angelegt.
|
||||||
|
|
||||||
|
**Demo-Zugänge** (Kennwort jeweils `baudoc2026`):
|
||||||
|
|
||||||
|
| E-Mail | Rolle |
|
||||||
|
|---|---|
|
||||||
|
| `admin@baudoc.local` | Administration |
|
||||||
|
| `m.kruse@infrago.local` | Projektleitung (PL) |
|
||||||
|
| `t.albrecht@infrago.local` | Bauherrenvertretung (BHV) |
|
||||||
|
| `j.hoffmann@infrago.local` | Bauüberwacher Bahn (BÜB) |
|
||||||
|
| `s.linnemann@infrago.local` | Fachbauüberwacherin (FBÜ) |
|
||||||
|
| `r.pauls@infrago.local` | Kaufmännische Steuerung |
|
||||||
|
| `d.wegener@bahnplan.local` | Planung |
|
||||||
|
| `bauleitung@nordbahn.local` | Auftragnehmer |
|
||||||
|
|
||||||
|
> Der interessanteste Einstieg: als **j.hoffmann** anmelden → Projekt öffnen →
|
||||||
|
> *Rechnungsprüfung* → Abschlagsrechnung `2026-AR-0431`.
|
||||||
|
|
||||||
|
## Technik
|
||||||
|
|
||||||
|
Bewusst schmal gehalten – vier bewegliche Teile, keine Fremddienste:
|
||||||
|
|
||||||
|
- **Next.js 16 / React 19 / TypeScript** – Oberfläche und Server-Logik in einem Prozess
|
||||||
|
- **PostgreSQL 16 + Prisma** – Datenhaltung
|
||||||
|
- **Eigene Session-Authentifizierung** (bcrypt, HttpOnly-Cookie) statt zusätzlichem Auth-Dienst
|
||||||
|
- **Nodemailer** gegen ein beliebiges SMTP-Relais; im Testbetrieb Mailpit
|
||||||
|
|
||||||
|
### Persistenz ohne Named Volumes
|
||||||
|
|
||||||
|
Alle persistenten Daten liegen als Bind-Mount direkt im Compose-Verzeichnis:
|
||||||
|
|
||||||
|
```
|
||||||
|
data/postgres/ Datenbankdateien
|
||||||
|
data/files/ hochgeladene Dokumente, als normale Dateien lesbar:
|
||||||
|
<Projektnummer>/<Jahr>/<Hash-Präfix>/<Hash>.<ext>
|
||||||
|
data/mail/ Mailpit-Postfach (nur Testbetrieb)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Backup ist damit `tar czf backup.tgz data/`, ein Umzug ein Kopiervorgang.
|
||||||
|
Die Ablage bleibt auch ohne die Anwendung lesbar; jede Datei trägt ihre
|
||||||
|
SHA-256-Prüfsumme in der Datenbank, nachträgliche Veränderungen sind erkennbar.
|
||||||
|
|
||||||
|
## Module
|
||||||
|
|
||||||
|
| Modul | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Dokumentationsstand** | Die Excel als lebendes Cockpit: Soll-Ist je Projekt, gefiltert auf die Fachbereiche, Fristen aus dem Übergabezeitpunkt, Ampel, Verantwortliche, CSV-Rückexport |
|
||||||
|
| **Dokumente (DMS)** | Versionierung, Prüf-/Freigabeworkflow, Vertraulichkeitsstufen, Volltext- und Metadatensuche, Zuordnung zur Ablagestruktur |
|
||||||
|
| **Bautagebuch** | Tageseinträge nach Ril 809.0301: Wetter, Betra/Sperrpause, Personal- und Gerätestunden, besondere Vorkommnisse; nach Abschluss gesperrt |
|
||||||
|
| **Meldungen & Mängel** | Behinderungs-, Bedenken-, Mängelanzeige, Mehrkosten/AvL, Unfall – mit Fristen und Nachverfolgung |
|
||||||
|
| **Verträge & Nachträge** | LV-Positionen, Nachtragsstatus und Stellungnahme der Bauüberwachung, Auftragsvolumen |
|
||||||
|
| **Stunden & Aufmaß** | Auftragnehmer reicht ein, Bauüberwachung erkennt **zeilenweise** an – das ist die Messlatte der Rechnungsprüfung |
|
||||||
|
| **Rechnungsprüfung** | Automatischer Abgleich gegen Vertrag, Aufmaß, Stundenzettel und Vorrechnungen; getrennte Bescheinigung *sachlich richtig* / *rechnerisch richtig* / Zahlungsfreigabe |
|
||||||
|
| **Kommunikation** | Projektkanäle statt E-Mail-Pingpong, `@Nachname`-Erwähnungen, E-Mail-Benachrichtigung je nach Profileinstellung |
|
||||||
|
| **Verwaltung** | Benutzer, Firmen, revisionssicheres Protokoll aller Vorgänge |
|
||||||
|
|
||||||
|
### Rechnungsprüfung – geprüfte Befunde
|
||||||
|
|
||||||
|
`src/lib/rechnungspruefung.ts` prüft je Rechnungsposition und auf Rechnungsebene:
|
||||||
|
|
||||||
|
- kein Vertragsbezug / unbekannte OZ
|
||||||
|
- Einheitspreis weicht vom Vertrag ab
|
||||||
|
- Menge ohne bzw. über anerkanntem Aufmaß (inkl. Anrechnung der Vorrechnungen)
|
||||||
|
- Stunden ohne Zettel, über anerkannten Stunden, abweichender Stundensatz,
|
||||||
|
unbelegter Zuschlag, nicht bestätigter Zettel
|
||||||
|
- Doppelabrechnung eines Stundenlohnzettels
|
||||||
|
- Nachtragsleistung ohne beauftragten Nachtrag
|
||||||
|
- Rechenfehler in Zeile, Umsatzsteuer und Brutto
|
||||||
|
- kumulierte Abrechnung über der Auftragssumme inkl. Nachträge
|
||||||
|
- Hinweis bei Mengenmehrung > 110 % (§ 2 Abs. 3 VOB/B)
|
||||||
|
|
||||||
|
Ergebnis ist ein Prüfbericht mit vorgeschlagener Kürzung. Die Entscheidung
|
||||||
|
trifft weiterhin ein Mensch; jeder Schritt wird protokolliert.
|
||||||
|
|
||||||
|
## Rollen und Berechtigungen
|
||||||
|
|
||||||
|
### Das Grundprinzip: zwei Seiten, nicht Hierarchie
|
||||||
|
|
||||||
|
Die Rollen bilden **nicht** ab, wer wem vorgesetzt ist, sondern auf welcher Seite
|
||||||
|
des Bauvertrags jemand steht. Das ist die Trennlinie, an der alle Rechte hängen:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **Auftraggeberseite (AG)** – der Bauherr, also das EIU | vergibt, überwacht, prüft, gibt frei. Sieht alles. Rollen: `PROJEKTLEITER`, `BAUHERRENVERTRETER`, `BAUUEBERWACHER`, `FACHBAUUEBERWACHER`, `KAUFMANN` |
|
||||||
|
| **Auftragnehmerseite (AN)** – die Baufirma | liefert, meldet, rechnet ab. Sieht ausschließlich die eigenen Daten. Rolle: `AUFTRAGNEHMER` |
|
||||||
|
| **Zuarbeitende und Mitlesende** | `PLANER`, `PRUEFER`, `BETREIBER`, `LESER` |
|
||||||
|
|
||||||
|
Wichtig für das Verständnis der Demo: `bauleitung@nordbahn.local` ist die **Bauleitung
|
||||||
|
der Baufirma**, also Auftragnehmerseite. Wer die Baustelle für den Bauherrn führt, ist
|
||||||
|
in der Bahnwelt nicht der „Bauleiter“, sondern der **Bauüberwacher Bahn (BÜB)** –
|
||||||
|
`j.hoffmann@infrago.local`.
|
||||||
|
|
||||||
|
### Die einzelnen Rollen
|
||||||
|
|
||||||
|
| Rolle | Wer das ist | Darf im Kern |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN` | IT-Administration | alles, zusätzlich Benutzerverwaltung und Protokoll |
|
||||||
|
| `PROJEKTLEITER` (PL) | Projektleitung beim EIU | Projekt anlegen und steuern, Rollen vergeben, Zahlungen freigeben |
|
||||||
|
| `BAUHERRENVERTRETER` (BHV) | Bauherrenvertretung vor Ort | Bautagebuch gegenzeichnen, freigeben, Zahlungen freigeben |
|
||||||
|
| `BAUUEBERWACHER` (BÜB) | Bauüberwacher Bahn | Bautagebuch führen, Stunden und Aufmaße anerkennen, **sachlich richtig** bescheinigen |
|
||||||
|
| `FACHBAUUEBERWACHER` (FBÜ) | Fachbauüberwachung | wie BÜB, aber beschränkt auf die zugewiesenen Fachbereiche; **keine** sachliche Bescheinigung |
|
||||||
|
| `KAUFMANN` | kaufmännische Projektsteuerung | Verträge und Nachträge pflegen, **rechnerisch richtig** bescheinigen |
|
||||||
|
| `PLANER` | Planungsbüro | Planunterlagen in die Ablagestruktur einstellen |
|
||||||
|
| `AUFTRAGNEHMER` (AN) | Bauleitung der Baufirma | Stundenzettel und Aufmaße einreichen, Behinderungs- und Bedenkenanzeigen stellen, Unterlagen einstellen |
|
||||||
|
| `PRUEFER` | Gutachter, Prüfingenieur, TÜV | Fachunterlagen lesen, Prüfvermerke und Meldungen erfassen |
|
||||||
|
| `BETREIBER` | Anlagenbetreiber / Instandhaltung | Betreiberakte (Teil II) lesen |
|
||||||
|
| `LESER` | Mitlesende ohne Aufgabe | nur lesen |
|
||||||
|
|
||||||
|
Die Zuordnung erfolgt **je Projekt**. Dieselbe Person kann in Projekt A Bauüberwacher
|
||||||
|
und in Projekt B nur Leser sein. Bei Fachbauüberwachern wird zusätzlich je Fachbereich
|
||||||
|
eingegrenzt.
|
||||||
|
|
||||||
|
### Vollständige Matrix
|
||||||
|
|
||||||
|
Quelle ist `app/src/lib/rbac.ts`; die Tabelle ist von dort erzeugt.
|
||||||
|
|
||||||
|
Spalte AN zeigt das Rollenrecht; ein `(○)` bedeutet: greift zusätzlich nur, wenn der
|
||||||
|
passende Projektschalter gesetzt ist (siehe Abschnitt *Je Projekt einstellbar*).
|
||||||
|
|
||||||
|
| Recht | ADM | PL | BHV | BÜB | FBÜ | KFM | PLA | AN | PRF | BTR | LES |
|
||||||
|
|---|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
|
||||||
|
| Projekt anlegen | × | × | | | | | | | | | |
|
||||||
|
| Projekt bearbeiten | × | × | × | | | | | | | | |
|
||||||
|
| Rollen im Projekt vergeben | × | × | | | | | | | | | |
|
||||||
|
| Projekteinstellungen ändern | × | × | × | | | | | | | | |
|
||||||
|
| Dokumente lesen | × | × | × | × | × | × | × | × | × | × | × |
|
||||||
|
| Dokumente einstellen | × | × | × | × | × | × | × | (○) | | | |
|
||||||
|
| Dokumente freigeben | × | × | × | × | × | | | | | | |
|
||||||
|
| Dokumente löschen | × | × | | | | | | | | | |
|
||||||
|
| Interne Unterlagen sehen | × | × | × | × | × | × | | | | | |
|
||||||
|
| Dokumentationsstand einsehen | × | × | × | × | × | × | × | (○) | × | × | × |
|
||||||
|
| Dokumentationsstand steuern | × | × | × | × | | | | | | | |
|
||||||
|
| Bautagebuch lesen | × | × | × | × | × | × | | (○) | × | | × |
|
||||||
|
| Bautagebuch führen | × | | | × | × | | | | | | |
|
||||||
|
| Bautagebuch gegenzeichnen | × | × | × | | | | | | | | |
|
||||||
|
| Meldung anlegen | × | × | × | × | × | | | × | × | | |
|
||||||
|
| Meldung bearbeiten | × | × | × | × | × | | | | | | |
|
||||||
|
| Meldung schließen | × | × | | × | | | | | | | |
|
||||||
|
| Verträge lesen | × | × | × | × | | × | | (○) | | | |
|
||||||
|
| Verträge bearbeiten | × | × | | | | × | | | | | |
|
||||||
|
| Nachtrag prüfen | × | × | | × | | | | | | | |
|
||||||
|
| Stundenzettel einreichen | × | | | | | | | × | | | |
|
||||||
|
| Stundenzettel anerkennen | × | | | × | × | | | | | | |
|
||||||
|
| Aufmaß einreichen | × | | | | | | | × | | | |
|
||||||
|
| Aufmaß anerkennen | × | | | × | × | | | | | | |
|
||||||
|
| Rechnung erfassen | × | | | | | × | | × | | | |
|
||||||
|
| Rechnung einsehen | × | × | × | × | | × | | (○) | | | |
|
||||||
|
| **sachlich richtig** | × | | | × | | | | | | | |
|
||||||
|
| **rechnerisch richtig** | × | | | | | × | | | | | |
|
||||||
|
| Zahlung freigeben | × | × | × | | | | | | | | |
|
||||||
|
| Nachrichten schreiben | × | × | × | × | × | × | × | × | × | × | |
|
||||||
|
| Kanal anlegen | × | × | × | × | | | | | | | |
|
||||||
|
| Benutzerverwaltung | × | | | | | | | | | | |
|
||||||
|
| Firmenstamm | × | × | | | | | | | | | |
|
||||||
|
| Protokoll einsehen | × | | | | | | | | | | |
|
||||||
|
|
||||||
|
### Je Projekt einstellbar
|
||||||
|
|
||||||
|
Was Auftragnehmer sehen dürfen, ist **keine Entscheidung im Code**, sondern eine des
|
||||||
|
Bauherrn – einstellbar beim Anlegen und jederzeit unter *Projekt → Einstellungen*
|
||||||
|
(nur Projektleitung und Bauherrenvertretung):
|
||||||
|
|
||||||
|
| Schalter | Voreinstellung | Wirkung |
|
||||||
|
|---|:-:|---|
|
||||||
|
| Eigenen Vertrag einsehen | **an** | Reiter „Verträge & Nachträge“ mit dem eigenen LV und den eigenen Nachträgen samt Stellungnahme der Bauüberwachung |
|
||||||
|
| Stand der eigenen Rechnungen verfolgen | **an** | Reiter „Rechnungsprüfung“ mit Prüfstand und Befunden zu den eigenen Rechnungen |
|
||||||
|
| Bautagebuch lesen | aus | Leserecht auf das Bautagebuch des Bauherrn |
|
||||||
|
| Dokumentationsstand einsehen | aus | Soll-Ist der Ablagestruktur – sinnvoll, wenn die Baufirma selbst zuliefert |
|
||||||
|
| Unterlagen einstellen | **an** | Nachweise und Protokolle direkt hochladen statt per E-Mail |
|
||||||
|
| Bescheinigung trotz kritischer Befunde | **an** | Erlaubt „sachlich richtig“ bei offenen Prüfbefunden – nur mit Begründung, protokolliert. Abgeschaltet müssen erst alle Befunde geklärt sein. |
|
||||||
|
|
||||||
|
**Diese Schalter steuern ausschließlich die Sichtbarkeit von Bereichen, niemals den
|
||||||
|
Datenumfang.** Ein Auftragnehmer sieht in jedem Fall nur Verträge, Aufmaße,
|
||||||
|
Stundenzettel und Rechnungen der eigenen Firma. Diese Schranke hängt allein an der
|
||||||
|
Rolle (`isExternalOnly()` in `src/lib/rbac.ts`) und ist durch keine Einstellung
|
||||||
|
aufhebbar; die Datenbankabfragen filtern zusätzlich auf `companyId`.
|
||||||
|
|
||||||
|
Bewusst nicht geschaltet: Der Reiter **Beteiligte** zeigt allen Projektbeteiligten,
|
||||||
|
welche Firmen und Personen mitwirken. Das entspricht Position B.I 0 der
|
||||||
|
Ablagestruktur, die genau diese Liste als aufzubewahrende Unterlage fordert.
|
||||||
|
|
||||||
|
### Projekt anlegen
|
||||||
|
|
||||||
|
Projektleitung und Administration legen Projekte unter *Projekte → Projekt anlegen* an.
|
||||||
|
Aus **Bauherrenseite** und **Fachbereichen** ergibt sich, welche der 944 Soll-Unterlagen
|
||||||
|
einschlägig sind – das Formular zeigt die Zahl live mit, bevor gespeichert wird. Aus den
|
||||||
|
**Terminen** werden Meilensteine und die Fristen der Soll-Unterlagen berechnet.
|
||||||
|
|
||||||
|
Zur Orientierung, wie stark die Fachbereichsauswahl wirkt (Bauherrenseite Fahrweg):
|
||||||
|
|
||||||
|
| Auswahl | Soll-Unterlagen |
|
||||||
|
|---|--:|
|
||||||
|
| nur Oberbau | 367 |
|
||||||
|
| Oberbau + LST | 425 |
|
||||||
|
| alle 9 Fachbereiche | 737 |
|
||||||
|
| Personenbahnhöfe, alle 7 Fachbereiche | 510 |
|
||||||
|
|
||||||
|
Werden Termine oder Fachbereiche später geändert, zieht *Einstellungen → Speichern und
|
||||||
|
Soll-Katalog nachziehen* fehlende Positionen nach und rechnet die Fristen aller noch
|
||||||
|
offenen Positionen neu. Einmal angelegte Positionen werden nie gelöscht – nicht mehr
|
||||||
|
zutreffende setzt man einzeln auf „entfällt“, dann bleibt der Grund dokumentiert.
|
||||||
|
|
||||||
|
### Absichtliche Sperren
|
||||||
|
|
||||||
|
Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache:
|
||||||
|
|
||||||
|
- **Sachlich und rechnerisch richtig kann nie dieselbe Person bescheinigen.** Die Prüfung
|
||||||
|
ist zusätzlich zeitlich gekoppelt: erst BÜB, dann kaufmännische Steuerung, dann Freigabe.
|
||||||
|
- **Der Bauüberwacher kann keine Zahlung freigeben** – er bescheinigt nur, dass die Leistung
|
||||||
|
erbracht ist. Die Freigabe liegt bei PL oder BHV.
|
||||||
|
- **Der Fachbauüberwacher kann nicht sachlich bescheinigen** – er arbeitet dem BÜB zu.
|
||||||
|
- **Auftragnehmer sehen nur die eigene Firma.** Verträge, Stunden, Aufmaße und Rechnungen
|
||||||
|
anderer Baufirmen sind für sie nicht vorhanden, ebenso wenig als intern oder vertraulich
|
||||||
|
gekennzeichnete Unterlagen.
|
||||||
|
- **Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht** – auch nicht in der Liste.
|
||||||
|
|
||||||
|
Sperren, die auf einer bewussten Handlung beruhen, werden protokolliert: etwa die
|
||||||
|
sachliche Bescheinigung trotz kritischer Prüfbefunde, die nur mit Begründung möglich ist.
|
||||||
|
|
||||||
|
## Bekannte Lücken im Rollenmodell
|
||||||
|
|
||||||
|
Keine offenen Punkte. Die drei zuvor dokumentierten Lücken sind geschlossen:
|
||||||
|
|
||||||
|
1. **Projektanlage** – Oberfläche vorhanden (`/projekte/neu`), Recht `projekt.anlegen`
|
||||||
|
wird geprüft, inklusive Mitgliederverwaltung unter *Einstellungen*.
|
||||||
|
2. **Auftragnehmer und eigener Vertrag** – über den Projektschalter *Eigenen Vertrag
|
||||||
|
einsehen* steuerbar, voreingestellt an.
|
||||||
|
3. **Auftragnehmer und eigene Rechnungen** – über den Projektschalter *Stand der eigenen
|
||||||
|
Rechnungen verfolgen* steuerbar, voreingestellt an.
|
||||||
|
|
||||||
|
Bei der Umsetzung fielen zwei weitere Stellen auf, an denen Auftragnehmern Daten
|
||||||
|
fremder Firmen angezeigt wurden; beide sind behoben:
|
||||||
|
|
||||||
|
- die Projekt-Übersichtsseite listete alle Verträge und Gesamtsummen des Projekts,
|
||||||
|
- das Erfassungsformular für Meldungen bot alle Firmen und Vertragsnummern zur Auswahl an.
|
||||||
|
|
||||||
|
Geprüft wird das durch einen Suchlauf über alle ausgelieferten Seiten – inklusive des
|
||||||
|
eingebetteten React-Payloads – auf Vertragsnummer, Firmenname, Kreditorennummer und
|
||||||
|
LV-Kurztexte einer Fremdfirma.
|
||||||
|
|
||||||
|
## Entwicklung ohne Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd app
|
||||||
|
npm install
|
||||||
|
cp ../.env.example .env # DATABASE_URL auf localhost:5433 zeigen lassen
|
||||||
|
npx prisma db push
|
||||||
|
npx tsx prisma/seed.ts
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Betrieb
|
||||||
|
|
||||||
|
- `AUTH_SECRET` vor dem Produktivbetrieb zwingend ersetzen.
|
||||||
|
- `SMTP_*` auf ein echtes Relais richten und den `mail`-Dienst entfernen.
|
||||||
|
- Die Anwendung terminiert kein TLS – hinter einen Reverse Proxy stellen.
|
||||||
|
- `MAX_UPLOAD_MB` steuert die Obergrenze je Datei (Standard 200 MB).
|
||||||
|
- Backup: `docker compose stop && tar czf backup-$(date +%F).tgz data/`
|
||||||
|
|
||||||
|
## Offene Punkte für die nächste Ausbaustufe
|
||||||
|
|
||||||
|
Bewusst noch nicht umgesetzt, weil dafür Rücksprache mit dem Auftraggeber nötig ist:
|
||||||
|
|
||||||
|
- Anbindung an vorhandene DB-Systeme (eBTB, DB-GIS, SAP-Kreditorenstamm, iTWO/GAEB)
|
||||||
|
- Import von LV und Rechnungen als GAEB/X-Rechnung statt manueller Erfassung
|
||||||
|
- Volltextindizierung von PDF-Inhalten (OCR)
|
||||||
|
- Anmeldung über das Unternehmensverzeichnis (LDAP/SSO)
|
||||||
|
- Mobile Erfassung vor Ort inkl. Fotodokumentation mit Geo-Bezug
|
||||||
|
- Automatische Fristenmails als Tageszusammenfassung (Cron)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
storage
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
RUN apk add --no-cache openssl tzdata
|
||||||
|
ENV TZ=Europe/Berlin
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ---- Abhängigkeiten ----
|
||||||
|
FROM base AS deps
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
# ---- Build ----
|
||||||
|
FROM base AS builder
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npx prisma generate && npm run build
|
||||||
|
|
||||||
|
# ---- Laufzeit ----
|
||||||
|
FROM base AS runner
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
ENV STORAGE_DIR=/app/storage
|
||||||
|
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/prisma ./prisma
|
||||||
|
# Der Seed nutzt dieselbe Fachlogik wie die Anwendung (src/lib/projekte).
|
||||||
|
COPY --from=builder /app/src ./src
|
||||||
|
COPY --from=builder /app/tsconfig.json ./tsconfig.json
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh && mkdir -p /app/storage
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["npm", "run", "start"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "[baudoc] Warte auf Datenbank ..."
|
||||||
|
i=0
|
||||||
|
until echo 'SELECT 1;' | npx prisma db execute --stdin >/dev/null 2>&1; do
|
||||||
|
i=$((i + 1))
|
||||||
|
if [ "$i" -ge 60 ]; then
|
||||||
|
echo "[baudoc] Datenbank nicht erreichbar - Abbruch."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[baudoc] Schema abgleichen ..."
|
||||||
|
npx prisma db push --skip-generate
|
||||||
|
|
||||||
|
echo "[baudoc] Stammdaten / Seed ..."
|
||||||
|
if ! npx tsx prisma/seed.ts; then
|
||||||
|
echo "!!!"
|
||||||
|
echo "!!! [baudoc] Der Seed ist fehlgeschlagen. Die Anwendung startet trotzdem,"
|
||||||
|
echo "!!! damit ein laufender Betrieb nicht durch einen Neustart ausfällt."
|
||||||
|
echo "!!! Bitte den Ablagestruktur-Katalog prüfen (Verwaltung > Ablagestruktur)."
|
||||||
|
echo "!!!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[baudoc] Start."
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
output: "standalone",
|
||||||
|
experimental: {
|
||||||
|
serverActions: { bodySizeLimit: "250mb" },
|
||||||
|
},
|
||||||
|
eslint: { ignoreDuringBuilds: true },
|
||||||
|
typescript: { ignoreBuildErrors: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
Generated
+2830
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "baudoc-bahn",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3000",
|
||||||
|
"build": "prisma generate && next build",
|
||||||
|
"start": "next start -p 3000",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:push": "prisma db push",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:studio": "prisma studio"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "6.3.1",
|
||||||
|
"bcryptjs": "2.4.3",
|
||||||
|
"date-fns": "4.1.0",
|
||||||
|
"jose": "5.9.6",
|
||||||
|
"lucide-react": "0.469.0",
|
||||||
|
"next": "^16.3.0",
|
||||||
|
"nodemailer": "6.9.16",
|
||||||
|
"react": "^19.2.8",
|
||||||
|
"react-dom": "^19.2.8",
|
||||||
|
"zod": "3.24.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "2.4.6",
|
||||||
|
"@types/node": "22.10.5",
|
||||||
|
"@types/nodemailer": "6.4.17",
|
||||||
|
"@types/react": "^19.2.18",
|
||||||
|
"@types/react-dom": "^19.2.4",
|
||||||
|
"autoprefixer": "10.4.20",
|
||||||
|
"postcss": "8.4.49",
|
||||||
|
"prisma": "6.3.1",
|
||||||
|
"tailwindcss": "3.4.17",
|
||||||
|
"tsx": "4.19.2",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,656 @@
|
|||||||
|
/**
|
||||||
|
* Seed: EIU-Ablagestruktur aus der Excel des Auftraggebers + Demo-Projekt.
|
||||||
|
*
|
||||||
|
* Der Katalog (FilingNode / DocumentRequirement) wird bei jedem Start
|
||||||
|
* idempotent aktualisiert. Demo-Daten werden nur angelegt, wenn die
|
||||||
|
* Datenbank noch leer ist.
|
||||||
|
*/
|
||||||
|
import { PrismaClient, type Akte, type Medium, type Uebergabezeitpunkt } from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { readFileSync } from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { slotsErzeugen } from "../src/lib/projekte";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
type RohKnoten = { code: string; parent: string | null; level: number; title: string; ril: string; akte: string };
|
||||||
|
type RohItem = {
|
||||||
|
seq: number; folder: string; akte: string; title: string; bullet: boolean; ril: string;
|
||||||
|
netz: boolean; sus: boolean; fbNetz: string[]; fbSuS: string[];
|
||||||
|
aufbNutzungsdauer: boolean; aufbJahre: string; medien: string[]; uebergabe: string[]; bemerkung: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const tage = (d: number) => new Date(Date.now() + d * 864e5);
|
||||||
|
|
||||||
|
async function ablagestrukturImportieren() {
|
||||||
|
const datei = path.join(process.cwd(), "prisma", "data", "ablagestruktur.json");
|
||||||
|
const roh = JSON.parse(readFileSync(datei, "utf-8")) as { nodes: RohKnoten[]; items: RohItem[] };
|
||||||
|
|
||||||
|
// Knoten nach Ebene, damit Elternknoten zuerst existieren
|
||||||
|
const sortiert = [...roh.nodes].sort((a, b) => a.level - b.level);
|
||||||
|
let i = 0;
|
||||||
|
for (const k of sortiert) {
|
||||||
|
await prisma.filingNode.upsert({
|
||||||
|
where: { code: k.code },
|
||||||
|
create: {
|
||||||
|
code: k.code,
|
||||||
|
parentCode: k.parent,
|
||||||
|
akte: k.akte as Akte,
|
||||||
|
level: k.level,
|
||||||
|
titel: k.title,
|
||||||
|
ril: k.ril || null,
|
||||||
|
sortIndex: i++,
|
||||||
|
},
|
||||||
|
update: { titel: k.title, ril: k.ril || null, parentCode: k.parent, level: k.level, sortIndex: i },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const it of roh.items) {
|
||||||
|
const fachbereiche = [...new Set([...it.fbNetz, ...it.fbSuS])];
|
||||||
|
await prisma.documentRequirement.upsert({
|
||||||
|
where: { ordnung: it.seq },
|
||||||
|
create: {
|
||||||
|
ordnung: it.seq,
|
||||||
|
nodeCode: it.folder,
|
||||||
|
titel: it.title,
|
||||||
|
ril: it.ril || null,
|
||||||
|
istUnterpunkt: it.bullet,
|
||||||
|
relevantNetz: it.netz,
|
||||||
|
relevantBahnhof: it.sus,
|
||||||
|
fachbereiche,
|
||||||
|
aufbewahrungNutzungsdauer: it.aufbNutzungsdauer,
|
||||||
|
aufbewahrungJahre: it.aufbJahre || null,
|
||||||
|
medien: it.medien as Medium[],
|
||||||
|
uebergabe: it.uebergabe as Uebergabezeitpunkt[],
|
||||||
|
bemerkung: it.bemerkung || null,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
nodeCode: it.folder,
|
||||||
|
titel: it.title,
|
||||||
|
ril: it.ril || null,
|
||||||
|
relevantNetz: it.netz,
|
||||||
|
relevantBahnhof: it.sus,
|
||||||
|
fachbereiche,
|
||||||
|
medien: it.medien as Medium[],
|
||||||
|
uebergabe: it.uebergabe as Uebergabezeitpunkt[],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` Ablagestruktur: ${sortiert.length} Ordner, ${roh.items.length} Soll-Unterlagen.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function demodatenAnlegen() {
|
||||||
|
const pw = await bcrypt.hash("baudoc2026", 11);
|
||||||
|
|
||||||
|
// ── Firmen ────────────────────────────────────────────────────────
|
||||||
|
const eiu = await prisma.company.create({
|
||||||
|
data: { name: "DB InfraGO AG – Regionalbereich Nord", shortName: "DB InfraGO", type: "EIU", city: "Hannover" },
|
||||||
|
});
|
||||||
|
const anGleisbau = await prisma.company.create({
|
||||||
|
data: { name: "Nordbahn Gleisbau GmbH", shortName: "Nordbahn", type: "AUFTRAGNEHMER", city: "Lüneburg", kreditorNr: "K-100482", ustId: "DE812345678" },
|
||||||
|
});
|
||||||
|
const anElt = await prisma.company.create({
|
||||||
|
data: { name: "Elektro Bahntechnik Weser GmbH", shortName: "EBW", type: "AUFTRAGNEHMER", city: "Bremen", kreditorNr: "K-100731" },
|
||||||
|
});
|
||||||
|
const planer = await prisma.company.create({
|
||||||
|
data: { name: "Ingenieurbüro Bahnplan Nord GmbH", shortName: "IB Bahnplan", type: "PLANUNGSBUERO", city: "Hamburg" },
|
||||||
|
});
|
||||||
|
const sicherung = await prisma.company.create({
|
||||||
|
data: { name: "RailSafe Sicherungsdienste GmbH", shortName: "RailSafe", type: "SICHERUNGSUNTERNEHMEN", city: "Celle" },
|
||||||
|
});
|
||||||
|
await prisma.company.create({
|
||||||
|
data: { name: "Eisenbahn-Bundesamt, Außenstelle Hannover", shortName: "EBA", type: "BEHOERDE", city: "Hannover" },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Benutzer ──────────────────────────────────────────────────────
|
||||||
|
const mk = (email: string, name: string, companyId: string, position: string, isAdmin = false) =>
|
||||||
|
prisma.user.create({ data: { email, name, passwordHash: pw, companyId, position, isAdmin } });
|
||||||
|
|
||||||
|
const admin = await mk("admin@baudoc.local", "System Administration", eiu.id, "IT-Administration", true);
|
||||||
|
const pl = await mk("m.kruse@infrago.local", "Martina Kruse", eiu.id, "Projektleitung (PL)");
|
||||||
|
const bhv = await mk("t.albrecht@infrago.local", "Thorben Albrecht", eiu.id, "Bauherrenvertretung (BHV)");
|
||||||
|
const buew = await mk("j.hoffmann@infrago.local", "Jens Hoffmann", eiu.id, "Bauüberwacher Bahn – Oberbau/KIB");
|
||||||
|
const fbue = await mk("s.linnemann@infrago.local", "Sabine Linnemann", eiu.id, "Fachbauüberwacherin – Elektrotechnik/OLA");
|
||||||
|
const kfm = await mk("r.pauls@infrago.local", "Rieke Pauls", eiu.id, "Kaufmännische Projektsteuerung");
|
||||||
|
const plan = await mk("d.wegener@bahnplan.local", "Dominik Wegener", planer.id, "Projektingenieur Planung");
|
||||||
|
const anUser = await mk("bauleitung@nordbahn.local", "Kai Petersen", anGleisbau.id, "Bauleitung Nordbahn Gleisbau");
|
||||||
|
const anUser2 = await mk("technik@ebw.local", "Yasemin Öztürk", anElt.id, "Bauleitung EBW");
|
||||||
|
const sich = await mk("dispo@railsafe.local", "Marco Dennig", sicherung.id, "Disposition Sicherungsleistungen");
|
||||||
|
|
||||||
|
// ── Projekt ───────────────────────────────────────────────────────
|
||||||
|
const fachbereiche = [
|
||||||
|
"Oberbau", "Tiefbau", "Konstruktiver Ingenieurbau",
|
||||||
|
"Elektrotechnik 50 Hz", "LST", "Oberleitung 16,7 Hz",
|
||||||
|
];
|
||||||
|
|
||||||
|
const heute = new Date();
|
||||||
|
const projekt = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
nummer: "P-2026-0147",
|
||||||
|
name: "Streckenertüchtigung Lüneburg – Uelzen, Abschnitt Bad Bevensen",
|
||||||
|
kurzname: "SE Bad Bevensen",
|
||||||
|
bauherr: "DB_INFRAGO_FAHRWEG",
|
||||||
|
status: "BAU",
|
||||||
|
regionalbereich: "Nord",
|
||||||
|
fachbereiche,
|
||||||
|
budgetPlan: 14_850_000,
|
||||||
|
beschreibung:
|
||||||
|
"Erneuerung Oberbau km 42,300 – 46,900 inkl. Weichen 1–6, Ersatzneubau EÜ Ilmenau (km 44,120), " +
|
||||||
|
"Anpassung Oberleitung 16,7 Hz, Kabeltiefbau und LST-Anpassung im Zuge der ESTW-Anbindung.",
|
||||||
|
startgespraech: tage(-420),
|
||||||
|
baubeginn: tage(-180),
|
||||||
|
bauende: tage(120),
|
||||||
|
inbetriebnahme: tage(95),
|
||||||
|
bauschlussmeldung: tage(150),
|
||||||
|
projektabschluss: tage(330),
|
||||||
|
abschnitte: {
|
||||||
|
create: [
|
||||||
|
{ streckenNr: "1720", streckenName: "Lehrte – Cuxhaven", kmVon: "42,300", kmBis: "46,900", betriebsstelle: "Bad Bevensen (ABBV)" },
|
||||||
|
{ streckenNr: "1720", streckenName: "Lehrte – Cuxhaven", kmVon: "44,100", kmBis: "44,150", betriebsstelle: "EÜ Ilmenau" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
bauwerke: {
|
||||||
|
create: [
|
||||||
|
{ bauwerksNr: "1720-044-120", bezeichnung: "EÜ Ilmenau", typ: "Eisenbahnüberführung", fachbereich: "Konstruktiver Ingenieurbau", kmLage: "44,120", ueberwachungspflichtig: true },
|
||||||
|
{ bauwerksNr: "1720-045-880", bezeichnung: "Stützbauwerk Nordrampe", typ: "Stützbauwerk", fachbereich: "Konstruktiver Ingenieurbau", kmLage: "45,880", ueberwachungspflichtig: true },
|
||||||
|
{ bezeichnung: "Weichen W1 – W6 Bad Bevensen", typ: "Gleisanlage", fachbereich: "Oberbau", kmLage: "43,100 – 43,650" },
|
||||||
|
{ bezeichnung: "Oberleitungsanlage Abschnitt Nord", typ: "Oberleitung", fachbereich: "Oberleitung 16,7 Hz", kmLage: "42,300 – 46,900" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
milestones: {
|
||||||
|
create: [
|
||||||
|
{ typ: "STARTGESPRAECH", titel: "Startgespräch Bauüberwachung", geplant: tage(-420), ist: tage(-418) },
|
||||||
|
{ typ: "BAUBEGINN", titel: "Baubeginn / Bauanlaufberatung", geplant: tage(-180), ist: tage(-180) },
|
||||||
|
{ typ: "ZWISCHEN_IBN", titel: "Zwischeninbetriebnahme Gleis 1", geplant: tage(-30), ist: tage(-28) },
|
||||||
|
{ typ: "INBETRIEBNAHME", titel: "Inbetriebnahme Gesamtmaßnahme", geplant: tage(95) },
|
||||||
|
{ typ: "BAUENDE", titel: "Bauende", geplant: tage(120) },
|
||||||
|
{ typ: "BAUSCHLUSSMELDUNG", titel: "Bauschlussmeldung", geplant: tage(150) },
|
||||||
|
{ typ: "PROJEKTABSCHLUSS", titel: "Projektabschluss (PA)", geplant: tage(330) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
members: {
|
||||||
|
create: [
|
||||||
|
{ userId: admin.id, role: "ADMIN" },
|
||||||
|
{ userId: pl.id, role: "PROJEKTLEITER" },
|
||||||
|
{ userId: bhv.id, role: "BAUHERRENVERTRETER" },
|
||||||
|
{ userId: buew.id, role: "BAUUEBERWACHER" },
|
||||||
|
{ userId: fbue.id, role: "FACHBAUUEBERWACHER", fachbereiche: ["Elektrotechnik 50 Hz", "Oberleitung 16,7 Hz", "LST"] },
|
||||||
|
{ userId: kfm.id, role: "KAUFMANN" },
|
||||||
|
{ userId: plan.id, role: "PLANER" },
|
||||||
|
{ userId: anUser.id, role: "AUFTRAGNEHMER" },
|
||||||
|
{ userId: anUser2.id, role: "AUFTRAGNEHMER" },
|
||||||
|
{ userId: sich.id, role: "AUFTRAGNEHMER" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Soll-Dokumentenkatalog für das Projekt erzeugen ────────────────
|
||||||
|
const { neu } = await slotsErzeugen(projekt.id);
|
||||||
|
console.log(` Soll-Dokumentenkatalog: ${neu} Positionen für ${projekt.nummer}.`);
|
||||||
|
|
||||||
|
// Realistischer Erfüllungsstand: Planungsakte weitgehend erledigt,
|
||||||
|
// Bauakte teilweise, Betreiberakte noch offen.
|
||||||
|
const slots = await prisma.projectDocumentSlot.findMany({
|
||||||
|
where: { projectId: projekt.id },
|
||||||
|
include: { requirement: { select: { nodeCode: true } } },
|
||||||
|
orderBy: { requirement: { ordnung: "asc" } },
|
||||||
|
});
|
||||||
|
let idx = 0;
|
||||||
|
for (const s of slots) {
|
||||||
|
const akte = s.requirement.nodeCode[0];
|
||||||
|
const wurf = (idx * 37) % 100;
|
||||||
|
idx++;
|
||||||
|
let status: "OFFEN" | "IN_ARBEIT" | "EINGEREICHT" | "IN_PRUEFUNG" | "FREIGEGEBEN" | "ENTFAELLT" = "OFFEN";
|
||||||
|
if (akte === "P") status = wurf < 78 ? "FREIGEGEBEN" : wurf < 88 ? "IN_PRUEFUNG" : wurf < 94 ? "ENTFAELLT" : "OFFEN";
|
||||||
|
else if (akte === "K") status = wurf < 46 ? "FREIGEGEBEN" : wurf < 60 ? "EINGEREICHT" : wurf < 70 ? "IN_ARBEIT" : "OFFEN";
|
||||||
|
else status = wurf < 34 ? "FREIGEGEBEN" : wurf < 46 ? "IN_PRUEFUNG" : wurf < 58 ? "IN_ARBEIT" : "OFFEN";
|
||||||
|
|
||||||
|
if (status !== "OFFEN") {
|
||||||
|
await prisma.projectDocumentSlot.update({
|
||||||
|
where: { id: s.id },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
eingangAm: status === "FREIGEGEBEN" || status === "EINGEREICHT" ? tage(-((wurf % 120) + 5)) : null,
|
||||||
|
responsibleId: akte === "K" ? kfm.id : akte === "P" ? plan.id : buew.id,
|
||||||
|
entfalltGrund: status === "ENTFAELLT" ? "Im Projektumfang nicht einschlägig (Vermerk PL)." : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vertrag Oberbau/KIB mit LV ────────────────────────────────────
|
||||||
|
const vertrag = await prisma.contract.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id,
|
||||||
|
companyId: anGleisbau.id,
|
||||||
|
nummer: "BV-2026-0147-01",
|
||||||
|
bezeichnung: "Oberbau- und Ingenieurbauleistungen Bad Bevensen",
|
||||||
|
typ: "BAUVERTRAG",
|
||||||
|
status: "IN_ABWICKLUNG",
|
||||||
|
vergabeart: "Offenes Verfahren (VOB/A EU)",
|
||||||
|
auftragssumme: 7_420_000,
|
||||||
|
beauftragtAm: tage(-210),
|
||||||
|
ausfuehrungVon: tage(-180),
|
||||||
|
ausfuehrungBis: tage(110),
|
||||||
|
gewaehrleistungJahre: 4,
|
||||||
|
sicherheitseinbehaltProzent: 5,
|
||||||
|
skontoProzent: 2,
|
||||||
|
skontoTage: 14,
|
||||||
|
zahlungszielTage: 30,
|
||||||
|
vertragsverantwortlich: "Rieke Pauls",
|
||||||
|
positionen: {
|
||||||
|
create: [
|
||||||
|
{ oz: "01.01.0010", kurztext: "Gleis ausbauen, Schotteroberbau", einheit: "m", menge: 4600, einheitspreis: 38.5 },
|
||||||
|
{ oz: "01.01.0020", kurztext: "Planumsschutzschicht herstellen, d = 30 cm", einheit: "m²", menge: 21800, einheitspreis: 24.9 },
|
||||||
|
{ oz: "01.02.0010", kurztext: "Gleis neu bauen, S 54, Betonschwelle B 70", einheit: "m", menge: 4600, einheitspreis: 412.0 },
|
||||||
|
{ oz: "01.02.0040", kurztext: "Weiche EW 60-500-1:12 einbauen", einheit: "St", menge: 6, einheitspreis: 148500.0 },
|
||||||
|
{ oz: "02.01.0010", kurztext: "EÜ Ilmenau – Überbau ausbauen", einheit: "psch", menge: 1, einheitspreis: 96000.0 },
|
||||||
|
{ oz: "02.01.0030", kurztext: "EÜ Ilmenau – Überbau einheben und lagern", einheit: "psch", menge: 1, einheitspreis: 384000.0 },
|
||||||
|
{ oz: "03.01.0010", kurztext: "Kabeltiefbau, Kabeltrog beidseitig", einheit: "m", menge: 4600, einheitspreis: 62.4 },
|
||||||
|
{ oz: "09.01.0010", kurztext: "Stundenlohnarbeiten Facharbeiter", einheit: "h", menge: 800, einheitspreis: 58.5, istStundenlohn: true },
|
||||||
|
{ oz: "09.01.0020", kurztext: "Stundenlohnarbeiten Polier", einheit: "h", menge: 200, einheitspreis: 74.0, istStundenlohn: true },
|
||||||
|
{ oz: "09.02.0010", kurztext: "Vorhaltung Zweiwegebagger im Stundenlohn", einheit: "h", menge: 300, einheitspreis: 132.0, istStundenlohn: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { positionen: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const p = (oz: string) => vertrag.positionen.find((x) => x.oz === oz)!;
|
||||||
|
|
||||||
|
await prisma.contract.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id,
|
||||||
|
companyId: anElt.id,
|
||||||
|
nummer: "BV-2026-0147-02",
|
||||||
|
bezeichnung: "Oberleitungs- und 50-Hz-Anlagen",
|
||||||
|
typ: "BAUVERTRAG",
|
||||||
|
status: "IN_ABWICKLUNG",
|
||||||
|
auftragssumme: 3_180_000,
|
||||||
|
beauftragtAm: tage(-195),
|
||||||
|
ausfuehrungVon: tage(-150),
|
||||||
|
ausfuehrungBis: tage(100),
|
||||||
|
positionen: {
|
||||||
|
create: [
|
||||||
|
{ oz: "05.01.0010", kurztext: "Oberleitungsmast setzen inkl. Fundament", einheit: "St", menge: 118, einheitspreis: 8940.0 },
|
||||||
|
{ oz: "05.02.0010", kurztext: "Kettenwerk Re200 montieren und regulieren", einheit: "m", menge: 4600, einheitspreis: 148.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nachtrag = await prisma.changeOrder.create({
|
||||||
|
data: {
|
||||||
|
contractId: vertrag.id,
|
||||||
|
nummer: "N-01",
|
||||||
|
titel: "Mehraufwand Bodenaustausch km 45,100 – 45,400 (kontaminiertes Material)",
|
||||||
|
begruendung:
|
||||||
|
"Bei Aushub wurde nicht ausgeschriebenes, mit Altöl belastetes Material angetroffen (Zuordnung Z 2). " +
|
||||||
|
"Entsorgung und Ersatz durch Frostschutzschicht erforderlich.",
|
||||||
|
rechtsgrundlage: "§ 2 Abs. 6 VOB/B",
|
||||||
|
angebotssumme: 214_800,
|
||||||
|
anerkannteSumme: 186_400,
|
||||||
|
status: "BEAUFTRAGT",
|
||||||
|
angemeldetAm: tage(-96),
|
||||||
|
geprueftAm: tage(-84),
|
||||||
|
beauftragtAm: tage(-72),
|
||||||
|
stellungnahme:
|
||||||
|
"Dem Grunde nach anerkannt. Mengenvordersätze auf Basis Abrechnungszeichnung geprüft. " +
|
||||||
|
"Einheitspreis Entsorgung auf marktübliches Niveau gekürzt (Nachtragsbewertungsmatrix).",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.changeOrder.create({
|
||||||
|
data: {
|
||||||
|
contractId: vertrag.id,
|
||||||
|
nummer: "N-02",
|
||||||
|
titel: "Zusätzliche Sperrpause wegen verschobener Betra KW 18",
|
||||||
|
begruendung: "Verschiebung der Betra durch Kapazitätsmanagement, Mehrkosten Vorhaltung und Sicherung.",
|
||||||
|
rechtsgrundlage: "§ 6 Abs. 6 VOB/B",
|
||||||
|
angebotssumme: 88_400,
|
||||||
|
status: "IN_PRUEFUNG_BUEW",
|
||||||
|
angemeldetAm: tage(-21),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Aufmaße ───────────────────────────────────────────────────────
|
||||||
|
const aufmass1 = await prisma.measurement.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, nummer: "AM-2026-014",
|
||||||
|
art: "ABRECHNUNGSZEICHNUNG", datum: tage(-38), ortBauteil: "km 42,300 – 44,100",
|
||||||
|
status: "ANERKANNT", createdById: buew.id, geprueftAm: tage(-34),
|
||||||
|
bemerkung: "Mengenermittlung nach REB 23.003, gemeinsam mit AN festgestellt.",
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
{ contractItemId: p("01.01.0010").id, beschreibung: "Gleis 1 Ausbau", formel: "1800,00", menge: 1800 },
|
||||||
|
{ contractItemId: p("01.02.0010").id, beschreibung: "Gleis 1 Neubau", formel: "1800,00", menge: 1800 },
|
||||||
|
{ contractItemId: p("01.01.0020").id, beschreibung: "PSS Gleis 1", formel: "1800,00 * 4,70", menge: 8460 },
|
||||||
|
{ contractItemId: p("01.02.0040").id, beschreibung: "W1, W2", formel: "2", menge: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.measurement.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, nummer: "AM-2026-021",
|
||||||
|
art: "OERTLICHES_AUFMASS", datum: tage(-12), ortBauteil: "EÜ Ilmenau km 44,120",
|
||||||
|
status: "TEILWEISE_ANERKANNT", createdById: buew.id, geprueftAm: tage(-9),
|
||||||
|
bemerkung: "Überbau Ausbau anerkannt. Einheben noch nicht erbracht – Position zurückgestellt.",
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
{ contractItemId: p("02.01.0010").id, beschreibung: "Überbau Ausbau", formel: "1", menge: 1, mengeAnerkannt: 1 },
|
||||||
|
{ contractItemId: p("02.01.0030").id, beschreibung: "Überbau einheben", formel: "1", menge: 1, mengeAnerkannt: 0 },
|
||||||
|
{ contractItemId: p("03.01.0010").id, beschreibung: "Kabeltrog Nordseite", formel: "1800,00", menge: 1800, mengeAnerkannt: 1650 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Stundenlohnzettel ─────────────────────────────────────────────
|
||||||
|
// Zettel 1: vollständig anerkannt
|
||||||
|
const zettel1 = await prisma.timeSheet.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, nummer: "SLZ-2026-088",
|
||||||
|
datum: tage(-26), leistung: "Freilegen und Sichern eines nicht dokumentierten Kabels km 43,480",
|
||||||
|
ortKm: "43,480", bezugPosition: "09.01.0010", status: "ANERKANNT",
|
||||||
|
filedById: anUser.id, checkedById: buew.id, geprueftAm: tage(-24),
|
||||||
|
pruefkommentar: "Leistung war angeordnet (AvL vom 12.), Stunden vor Ort bestätigt.",
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
{ name: "M. Sobczak", art: "FACHARBEITER", stundenGemeldet: 8, stundenAnerkannt: 8, stundensatz: 58.5 },
|
||||||
|
{ name: "T. Ehlert", art: "FACHARBEITER", stundenGemeldet: 8, stundenAnerkannt: 8, stundensatz: 58.5 },
|
||||||
|
{ name: "R. Danneberg", art: "POLIER", stundenGemeldet: 4, stundenAnerkannt: 4, stundensatz: 74.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zettel 2: gekürzt (An- und Abfahrt nicht anerkannt) – klassischer Streitfall
|
||||||
|
const zettel2 = await prisma.timeSheet.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, nummer: "SLZ-2026-091",
|
||||||
|
datum: tage(-19), leistung: "Handschachtung im Bereich Bestandskabel W4, Sperrpause 22:00 – 05:00",
|
||||||
|
ortKm: "43,610", bezugPosition: "09.01.0010", status: "TEILWEISE_ANERKANNT",
|
||||||
|
filedById: anUser.id, checkedById: buew.id, geprueftAm: tage(-17),
|
||||||
|
pruefkommentar:
|
||||||
|
"An- und Abfahrtszeiten sind mit den Einheitspreisen abgegolten und wurden abgesetzt. " +
|
||||||
|
"Nachtzuschlag 25 % für die Sperrpause anerkannt.",
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
{ name: "M. Sobczak", art: "FACHARBEITER", stundenGemeldet: 9, stundenAnerkannt: 7, stundensatz: 58.5, zuschlagProzent: 25, zuschlagGrund: "Nachtarbeit in Sperrpause" },
|
||||||
|
{ name: "T. Ehlert", art: "FACHARBEITER", stundenGemeldet: 9, stundenAnerkannt: 7, stundensatz: 58.5, zuschlagProzent: 25, zuschlagGrund: "Nachtarbeit in Sperrpause" },
|
||||||
|
{ name: "Zweiwegebagger ZW-14", art: "GERAET", stundenGemeldet: 7, stundenAnerkannt: 7, stundensatz: 132.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zettel 3: noch nicht geprüft – darf nicht abgerechnet werden
|
||||||
|
const zettel3 = await prisma.timeSheet.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, nummer: "SLZ-2026-097",
|
||||||
|
datum: tage(-6), leistung: "Nacharbeiten Entwässerungsrinne km 45,900",
|
||||||
|
ortKm: "45,900", status: "EINGEREICHT", filedById: anUser.id,
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
{ name: "K. Wiese", art: "FACHARBEITER", stundenGemeldet: 6, stundensatz: 58.5 },
|
||||||
|
{ name: "L. Brandt", art: "HELFER", stundenGemeldet: 6, stundensatz: 46.0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Rechnung mit bewusst eingebauten Prüfbefunden ─────────────────
|
||||||
|
const rechnung = await prisma.invoice.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, companyId: anGleisbau.id,
|
||||||
|
rechnungsNr: "2026-AR-0431", typ: "ABSCHLAGSRECHNUNG", status: "EINGEGANGEN",
|
||||||
|
rechnungsdatum: tage(-4), eingangsdatum: tage(-3),
|
||||||
|
leistungVon: tage(-60), leistungBis: tage(-5),
|
||||||
|
nettoBetrag: 2_166_283.0, ustSatz: 19, ustBetrag: 411_593.77, bruttoBetrag: 2_577_876.77,
|
||||||
|
zahlbarBis: tage(27),
|
||||||
|
zeilen: {
|
||||||
|
create: [
|
||||||
|
// korrekt
|
||||||
|
{ position: 1, art: "LV_POSITION", oz: "01.01.0010", bezeichnung: "Gleis ausbauen, Schotteroberbau",
|
||||||
|
einheit: "m", menge: 1800, einheitspreis: 38.5, betrag: 69_300, contractItemId: p("01.01.0010").id },
|
||||||
|
// korrekt
|
||||||
|
{ position: 2, art: "LV_POSITION", oz: "01.02.0010", bezeichnung: "Gleis neu bauen S 54",
|
||||||
|
einheit: "m", menge: 1800, einheitspreis: 412.0, betrag: 741_600, contractItemId: p("01.02.0010").id },
|
||||||
|
// Befund: Einheitspreis über Vertrag
|
||||||
|
{ position: 3, art: "LV_POSITION", oz: "01.01.0020", bezeichnung: "Planumsschutzschicht d = 30 cm",
|
||||||
|
einheit: "m²", menge: 8460, einheitspreis: 26.4, betrag: 223_344, contractItemId: p("01.01.0020").id },
|
||||||
|
// Befund: Menge über anerkanntem Aufmaß (3 statt 2 Weichen)
|
||||||
|
{ position: 4, art: "LV_POSITION", oz: "01.02.0040", bezeichnung: "Weiche EW 60-500-1:12 einbauen",
|
||||||
|
einheit: "St", menge: 3, einheitspreis: 148_500, betrag: 445_500, contractItemId: p("01.02.0040").id },
|
||||||
|
// Befund: Position ohne anerkanntes Aufmaß (Überbau noch nicht eingehoben)
|
||||||
|
{ position: 5, art: "LV_POSITION", oz: "02.01.0030", bezeichnung: "EÜ Ilmenau – Überbau einheben",
|
||||||
|
einheit: "psch", menge: 1, einheitspreis: 384_000, betrag: 384_000, contractItemId: p("02.01.0030").id },
|
||||||
|
// korrekt
|
||||||
|
{ position: 6, art: "STUNDENLOHN", oz: "SLZ-2026-088", bezeichnung: "Stundenlohn SLZ-2026-088",
|
||||||
|
einheit: "h", menge: 20, einheitspreis: 61.6, betrag: 1_232, timeSheetId: zettel1.id },
|
||||||
|
// Befund: gemeldete statt anerkannte Stunden
|
||||||
|
{ position: 7, art: "STUNDENLOHN", oz: "SLZ-2026-091", bezeichnung: "Stundenlohn SLZ-2026-091",
|
||||||
|
einheit: "h", menge: 25, einheitspreis: 78.4, betrag: 1_960, timeSheetId: zettel2.id },
|
||||||
|
// Befund: Zettel noch nicht bestätigt
|
||||||
|
{ position: 8, art: "STUNDENLOHN", oz: "SLZ-2026-097", bezeichnung: "Stundenlohn SLZ-2026-097",
|
||||||
|
einheit: "h", menge: 12, einheitspreis: 52.25, betrag: 627, timeSheetId: zettel3.id },
|
||||||
|
// Befund: Nachtragsposition ohne beauftragten Nachtrag ist hier korrekt zugeordnet
|
||||||
|
{ position: 9, art: "NACHTRAG", bezeichnung: "N-01 Bodenaustausch km 45,100 – 45,400",
|
||||||
|
einheit: "psch", menge: 1, einheitspreis: 186_400, betrag: 186_400, changeOrderId: nachtrag.id },
|
||||||
|
// Befund: Kabeltrog über anerkanntem Aufmaß (1800 statt 1650 m)
|
||||||
|
{ position: 10, art: "LV_POSITION", oz: "03.01.0010", bezeichnung: "Kabeltiefbau, Kabeltrog",
|
||||||
|
einheit: "m", menge: 1800, einheitspreis: 62.4, betrag: 112_320, contractItemId: p("03.01.0010").id },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Bautagebuch ───────────────────────────────────────────────────
|
||||||
|
const wetter = [
|
||||||
|
["heiter", "sonnig", "bewölkt", 8, 19],
|
||||||
|
["Nebel", "bedeckt", "Regen", 5, 12],
|
||||||
|
["bedeckt", "Regen", "Regen", 6, 11],
|
||||||
|
["klar", "heiter", "klar", 3, 16],
|
||||||
|
["bewölkt", "bewölkt", "heiter", 7, 17],
|
||||||
|
];
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const w = wetter[i];
|
||||||
|
const eintrag = await prisma.siteDiaryEntry.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, datum: tage(-(i + 1)), lfdNr: 178 - i, authorId: buew.id,
|
||||||
|
wetterFrueh: String(w[0]), wetterMittag: String(w[1]), wetterAbend: String(w[2]),
|
||||||
|
tempMin: Number(w[3]), tempMax: Number(w[4]),
|
||||||
|
arbeitszeitVon: "06:30", arbeitszeitBis: "16:00",
|
||||||
|
betra: i < 2 ? "Betra 2026/0412, Gleis 2 gesperrt 22:00 – 05:00" : "Betra 2026/0398",
|
||||||
|
sperrpause: i < 2 ? "22:00 – 05:00, Gleis 2" : "keine",
|
||||||
|
leistungen:
|
||||||
|
i === 0
|
||||||
|
? "Gleisneubau km 43,900 – 44,050 (150 m), Schotterverteilung und erstes Stopfen. Kabeltrogmontage Nordseite fortgesetzt.\nEÜ Ilmenau: Vorbereitung Lagerversetzung, Traggerüst geprüft."
|
||||||
|
: "Gleisneubau fortgesetzt, PSS eingebaut und verdichtet. Weiche W4 vormontiert.",
|
||||||
|
besondereVorkommnisse:
|
||||||
|
i === 0
|
||||||
|
? "Bei Aushub km 43,480 nicht dokumentiertes Kabel angetroffen. Arbeiten örtlich unterbrochen, DB Kommunikationstechnik verständigt (14:20 Uhr, Hr. Lembke)."
|
||||||
|
: i === 2
|
||||||
|
? "Starkregen ab 11:30, Verdichtungsarbeiten eingestellt."
|
||||||
|
: null,
|
||||||
|
einweisungen: i === 4 ? "Ersteinweisung 6 Mitarbeiter Nordbahn Gleisbau, Baustellenbelehrung nach Ril 132.0108V11." : null,
|
||||||
|
sicherungsmassnahmen: "Sicherung durch automatisches Warnsystem (AWS), Sicherungsplan Rev. 3, RailSafe.",
|
||||||
|
status: i === 0 ? "ENTWURF" : "ABGESCHLOSSEN",
|
||||||
|
signedById: i === 0 ? null : buew.id,
|
||||||
|
signedAt: i === 0 ? null : tage(-(i + 1)),
|
||||||
|
gesperrtAm: i > 1 ? tage(-(i)) : null,
|
||||||
|
personal: {
|
||||||
|
create: [
|
||||||
|
{ companyId: anGleisbau.id, gewerk: "Oberbau", taetigkeit: "Gleisbaukolonne", anzahl: 12, stunden: 108 },
|
||||||
|
{ companyId: anGleisbau.id, gewerk: "Konstruktiver Ingenieurbau", taetigkeit: "EÜ Ilmenau", anzahl: 5, stunden: 45 },
|
||||||
|
{ companyId: sicherung.id, gewerk: "Sicherung", taetigkeit: "Sicherungsposten / AWS", anzahl: 3, stunden: 27 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
geraete: {
|
||||||
|
create: [
|
||||||
|
{ bezeichnung: "Zweiwegebagger ZW-14", anzahl: 1, stunden: 9 },
|
||||||
|
{ bezeichnung: "Stopfmaschine 09-32", anzahl: 1, stunden: 6 },
|
||||||
|
{ bezeichnung: "Autokran 200 t", anzahl: i === 0 ? 1 : 0, stunden: i === 0 ? 8 : 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (i === 0) {
|
||||||
|
await prisma.timeSheet.update({ where: { id: zettel1.id }, data: { diaryEntryId: eintrag.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Meldungen / Vorgänge ──────────────────────────────────────────
|
||||||
|
await prisma.issue.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
projectId: projekt.id, nr: 1, typ: "BEHINDERUNGSANZEIGE",
|
||||||
|
titel: "Nicht dokumentiertes Kabel km 43,480 – Behinderung Gleisbau",
|
||||||
|
beschreibung:
|
||||||
|
"Beim Aushub wurde ein im Bestandsplan nicht enthaltenes Kabel angetroffen. Der Gleisbau in diesem Bereich ruht " +
|
||||||
|
"bis zur Freigabe durch DB Kommunikationstechnik. Bauzeitverzug wird angezeigt.",
|
||||||
|
status: "IN_BEARBEITUNG", schwere: "HOCH", ortKm: "43,480", fachbereich: "Oberbau",
|
||||||
|
raisedById: anUser.id, assigneeId: buew.id, gegenFirmaId: null, contractId: vertrag.id,
|
||||||
|
gemeldetAm: tage(-26), fristAm: tage(2), kostenrelevant: true, terminrelevant: true, geschaetzteKosten: 42_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: projekt.id, nr: 2, typ: "MANGELANZEIGE",
|
||||||
|
titel: "Schweißnaht SK 41 – Ultraschallprüfung ohne Nachweis",
|
||||||
|
beschreibung:
|
||||||
|
"Für die Schweißstelle SK 41 (km 43,215) liegt kein Prüfprotokoll nach Ril 824.9006 vor. " +
|
||||||
|
"Nachweis oder Nacharbeit ist bis zum Fristende beizubringen.",
|
||||||
|
status: "OFFEN", schwere: "SICHERHEITSRELEVANT", ortKm: "43,215", fachbereich: "Oberbau",
|
||||||
|
raisedById: buew.id, assigneeId: anUser.id, gegenFirmaId: anGleisbau.id, contractId: vertrag.id,
|
||||||
|
gemeldetAm: tage(-8), fristAm: tage(-1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: projekt.id, nr: 3, typ: "BEDENKENANZEIGE",
|
||||||
|
titel: "Bedenken gegen vorgesehene Verdichtungsleistung bei anhaltender Nässe",
|
||||||
|
beschreibung:
|
||||||
|
"Nach Starkregen ist die geforderte Verdichtung der PSS im Abschnitt km 45,2 – 45,6 nach unserer Einschätzung " +
|
||||||
|
"nicht erreichbar. Bedenken werden gemäß § 4 Abs. 3 VOB/B angemeldet.",
|
||||||
|
status: "ERLEDIGT", schwere: "MITTEL", ortKm: "45,200 – 45,600", fachbereich: "Tiefbau",
|
||||||
|
raisedById: anUser.id, assigneeId: buew.id, contractId: vertrag.id,
|
||||||
|
gemeldetAm: tage(-40), erledigtAm: tage(-31),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: projekt.id, nr: 4, typ: "MEHRKOSTEN_AVL",
|
||||||
|
titel: "Anordnung von Leistungen: zusätzliche Kabelsuchschachtungen",
|
||||||
|
beschreibung: "Anordnung des BÜB vom 12., Ausführung im Stundenlohn nach SLZ-2026-088.",
|
||||||
|
status: "ERLEDIGT", schwere: "GERING", raisedById: buew.id, contractId: vertrag.id,
|
||||||
|
gemeldetAm: tage(-27), erledigtAm: tage(-24), kostenrelevant: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectId: projekt.id, nr: 5, typ: "VERTRAGSABWEICHUNG",
|
||||||
|
titel: "Abweichende Schwellenlieferung – Zulassung prüfen",
|
||||||
|
beschreibung:
|
||||||
|
"Angeliefert wurden Betonschwellen einer nicht im Vertrag benannten Bauart. " +
|
||||||
|
"Verwendbarkeitsnachweis und Zulassung sind vor Einbau vorzulegen.",
|
||||||
|
status: "IN_BEARBEITUNG", schwere: "HOCH", fachbereich: "Oberbau",
|
||||||
|
raisedById: fbue.id, assigneeId: anUser.id, gegenFirmaId: anGleisbau.id, contractId: vertrag.id,
|
||||||
|
gemeldetAm: tage(-3), fristAm: tage(4),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.warranty.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
projectId: projekt.id, contractId: vertrag.id, gegenstand: "Zwischeninbetriebnahme Gleis 1, km 42,300 – 44,100",
|
||||||
|
abnahmeAm: tage(-28), fristJahre: 4, fristEndeAm: tage(-28 + 4 * 365),
|
||||||
|
verantwortlich: "Jens Hoffmann (BÜB)", buergschaft: 371_000, status: "LAUFEND",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Kommunikation ─────────────────────────────────────────────────
|
||||||
|
const kanalAllgemein = await prisma.channel.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, name: "Baustelle allgemein", kind: "PROJEKT",
|
||||||
|
beschreibung: "Tagesabstimmung aller Projektbeteiligten.",
|
||||||
|
mitglieder: { create: [pl, bhv, buew, fbue, kfm, plan, anUser, anUser2, sich].map((u) => ({ userId: u.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kanalAbrechnung = await prisma.channel.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, name: "Abrechnung & Nachträge", kind: "THEMA", privat: true,
|
||||||
|
beschreibung: "Nur Bauherrenseite: Aufmaße, Stundenzettel, Rechnungsprüfung.",
|
||||||
|
mitglieder: { create: [pl, bhv, buew, kfm].map((u) => ({ userId: u.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.channel.create({
|
||||||
|
data: {
|
||||||
|
projectId: projekt.id, name: "Oberleitung / 50 Hz", kind: "FACHBEREICH",
|
||||||
|
beschreibung: "Fachliche Abstimmung Elektrotechnik.",
|
||||||
|
mitglieder: { create: [fbue, pl, anUser2, buew].map((u) => ({ userId: u.id })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.message.createMany({
|
||||||
|
data: [
|
||||||
|
{ channelId: kanalAllgemein.id, authorId: buew.id, createdAt: tage(-1.4),
|
||||||
|
text: "Betra 2026/0412 ist für heute Nacht bestätigt, Sperrung Gleis 2 von 22:00 bis 05:00. Einweisung um 21:30 am Container Nord." },
|
||||||
|
{ channelId: kanalAllgemein.id, authorId: anUser.id, createdAt: tage(-1.3),
|
||||||
|
text: "Verstanden. Wir sind mit 12 Mann und dem Zweiwegebagger vor Ort. @Hoffmann bitte kurz bestätigen, dass der Autokran bis 06:00 stehen bleiben darf." },
|
||||||
|
{ channelId: kanalAllgemein.id, authorId: buew.id, createdAt: tage(-1.25),
|
||||||
|
text: "Bestätigt, der Kran kann bis 06:00 stehen bleiben. Standsicherheitsnachweis liegt in der Ablage unter B.I.1.3." },
|
||||||
|
{ channelId: kanalAllgemein.id, authorId: fbue.id, createdAt: tage(-0.6),
|
||||||
|
text: "Hinweis für alle: ab Montag ist der Abschnitt km 45,2 – 45,6 unter Spannung. Arbeiten im Gefahrenbereich nur nach Schalt- und Erdungsprotokoll." },
|
||||||
|
{ channelId: kanalAbrechnung.id, authorId: kfm.id, createdAt: tage(-0.4),
|
||||||
|
text: "Die Abschlagsrechnung 2026-AR-0431 von Nordbahn ist eingegangen. Automatische Prüfung hat mehrere kritische Befunde ausgeworfen – @Hoffmann magst du sachlich draufschauen, bevor ich rechnerisch prüfe?" },
|
||||||
|
{ channelId: kanalAbrechnung.id, authorId: buew.id, createdAt: tage(-0.3),
|
||||||
|
text: "Schaue ich mir heute Nachmittag an. Position 5 (Überbau einheben) kann definitiv noch nicht abgerechnet werden, der Überbau liegt noch auf dem Lagerplatz." },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.task.createMany({
|
||||||
|
data: [
|
||||||
|
{ projectId: projekt.id, titel: "Prüfprotokoll Schweißstelle SK 41 nachfordern", assigneeId: buew.id, faelligAm: tage(1), status: "OFFEN" },
|
||||||
|
{ projectId: projekt.id, titel: "Nachtrag N-02 bewerten (Nachtragsbewertungsmatrix)", assigneeId: buew.id, faelligAm: tage(5), status: "IN_ARBEIT" },
|
||||||
|
{ projectId: projekt.id, titel: "Bestandsunterlagen Kabeltrasse in DB-GIS einarbeiten lassen", assigneeId: plan.id, faelligAm: tage(30), status: "OFFEN" },
|
||||||
|
{ projectId: projekt.id, titel: "Abschlagsrechnung 2026-AR-0431 rechnerisch prüfen", assigneeId: kfm.id, faelligAm: tage(6), status: "OFFEN" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return { projekt, rechnung };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("[seed] Ablagestruktur importieren ...");
|
||||||
|
await ablagestrukturImportieren();
|
||||||
|
|
||||||
|
const vorhanden = await prisma.project.count();
|
||||||
|
if (vorhanden > 0) {
|
||||||
|
console.log("[seed] Projekte vorhanden – Demo-Daten werden übersprungen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[seed] Demo-Daten anlegen ...");
|
||||||
|
const { rechnung } = await demodatenAnlegen();
|
||||||
|
|
||||||
|
console.log("[seed] Rechnungsprüfung ausführen ...");
|
||||||
|
const { pruefeRechnung } = await import("../src/lib/rechnungspruefung");
|
||||||
|
const bericht = await pruefeRechnung(rechnung.id);
|
||||||
|
console.log(
|
||||||
|
` ${bericht.befunde} Befunde (${bericht.kritisch} kritisch), ` +
|
||||||
|
`Kürzung ${bericht.kuerzungNetto.toLocaleString("de-DE")} EUR.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("\n[seed] Fertig. Zugänge (Kennwort jeweils: baudoc2026)");
|
||||||
|
console.log(" admin@baudoc.local Administration");
|
||||||
|
console.log(" m.kruse@infrago.local Projektleitung");
|
||||||
|
console.log(" j.hoffmann@infrago.local Bauüberwacher Bahn");
|
||||||
|
console.log(" r.pauls@infrago.local Kaufmännische Steuerung");
|
||||||
|
console.log(" bauleitung@nordbahn.local Auftragnehmer");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(() => prisma.$disconnect());
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Leer } from "@/components/ui";
|
||||||
|
import { relativ } from "@/lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ICON: Record<string, string> = {
|
||||||
|
ERWAEHNUNG: "@",
|
||||||
|
NACHRICHT: "✉",
|
||||||
|
ZUWEISUNG: "→",
|
||||||
|
FRIST: "⏱",
|
||||||
|
FREIGABE_ANGEFORDERT: "✓",
|
||||||
|
DOKUMENT_NEU: "📄",
|
||||||
|
RECHNUNG_BEFUND: "€",
|
||||||
|
MANGEL: "!",
|
||||||
|
SYSTEM: "i",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Benachrichtigungen() {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
const eintraege = await prisma.notification.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function alleGelesen() {
|
||||||
|
"use server";
|
||||||
|
const u = await requireUser();
|
||||||
|
await prisma.notification.updateMany({
|
||||||
|
where: { userId: u.id, gelesenAm: null },
|
||||||
|
data: { gelesenAm: new Date() },
|
||||||
|
});
|
||||||
|
revalidatePath("/benachrichtigungen");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ungelesen = eintraege.filter((e) => !e.gelesenAm).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Benachrichtigungen</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-db-muted">{ungelesen} ungelesen</p>
|
||||||
|
</div>
|
||||||
|
{ungelesen > 0 && (
|
||||||
|
<form action={alleGelesen}>
|
||||||
|
<button className="btn-ghost btn-sm">alle als gelesen markieren</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Leer text="Keine Benachrichtigungen." />
|
||||||
|
) : (
|
||||||
|
<ul className="card divide-y divide-db-line/70 overflow-hidden">
|
||||||
|
{eintraege.map((e) => (
|
||||||
|
<li key={e.id} className={`flex gap-3 px-4 py-3 ${!e.gelesenAm ? "bg-db-cyan/5" : ""}`}>
|
||||||
|
<span className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded bg-db-line text-xs text-slate-300">
|
||||||
|
{ICON[e.typ] ?? "•"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{e.link ? (
|
||||||
|
<Link href={e.link} className="text-sm font-medium text-slate-100 hover:text-db-cyan">
|
||||||
|
{e.titel}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium text-slate-100">{e.titel}</span>
|
||||||
|
)}
|
||||||
|
{e.text && <p className="mt-0.5 line-clamp-3 text-xs leading-relaxed text-db-muted">{e.text}</p>}
|
||||||
|
<p className="mt-1 text-[11px] text-db-muted">
|
||||||
|
{relativ(e.createdAt)}
|
||||||
|
{e.gemailtAm && " · per E-Mail zugestellt"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!e.gelesenAm && <span className="mt-2 h-1.5 w-1.5 shrink-0 rounded-full bg-db-red" />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Bell, LogOut, LayoutGrid, FolderKanban, Settings } from "lucide-react";
|
||||||
|
import { requireUser, logout } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { NavLink } from "@/components/NavLink";
|
||||||
|
|
||||||
|
export default async function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const ungelesen = await prisma.notification.count({ where: { userId: user.id, gelesenAm: null } });
|
||||||
|
|
||||||
|
async function abmelden() {
|
||||||
|
"use server";
|
||||||
|
await logout();
|
||||||
|
redirect("/anmelden");
|
||||||
|
}
|
||||||
|
|
||||||
|
const navCls =
|
||||||
|
"flex items-center gap-2 rounded-md px-3 py-1.5 text-sm text-db-muted transition hover:bg-db-line hover:text-white";
|
||||||
|
const navAktiv = "!bg-db-line !text-white";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen">
|
||||||
|
<header className="sticky top-0 z-30 border-b border-db-line bg-db-dark/95 backdrop-blur">
|
||||||
|
<div className="mx-auto flex h-14 max-w-[1600px] items-center gap-6 px-5">
|
||||||
|
<Link href="/uebersicht" className="flex shrink-0 items-center gap-2.5">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded bg-db-red text-sm font-bold text-white">
|
||||||
|
BD
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-sm font-semibold text-white sm:block">BauDoc Bahn</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="flex items-center gap-1">
|
||||||
|
<NavLink href="/uebersicht" className={navCls} activeClassName={navAktiv}>
|
||||||
|
<LayoutGrid size={15} /> Übersicht
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/projekte" className={navCls} activeClassName={navAktiv}>
|
||||||
|
<FolderKanban size={15} /> Projekte
|
||||||
|
</NavLink>
|
||||||
|
{user.isAdmin && (
|
||||||
|
<NavLink href="/verwaltung" className={navCls} activeClassName={navAktiv}>
|
||||||
|
<Settings size={15} /> Verwaltung
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/benachrichtigungen"
|
||||||
|
className="relative rounded-md p-2 text-db-muted transition hover:bg-db-line hover:text-white"
|
||||||
|
title="Benachrichtigungen"
|
||||||
|
>
|
||||||
|
<Bell size={17} />
|
||||||
|
{ungelesen > 0 && (
|
||||||
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-db-red px-1 text-[10px] font-bold text-white">
|
||||||
|
{ungelesen > 99 ? "99+" : ungelesen}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link href="/profil" className="hidden text-right leading-tight md:block">
|
||||||
|
<div className="text-sm text-slate-200">{user.name}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">{user.companyName ?? "—"}</div>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<form action={abmelden}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="rounded-md p-2 text-db-muted transition hover:bg-db-line hover:text-white"
|
||||||
|
title="Abmelden"
|
||||||
|
>
|
||||||
|
<LogOut size={17} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto max-w-[1600px] px-5 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { requireUser, hashPassword, verifyPassword } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ROLE_LABEL } from "@/lib/rbac";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Profil({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
|
||||||
|
const session = await requireUser();
|
||||||
|
const sp = await searchParams;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUniqueOrThrow({
|
||||||
|
where: { id: session.id },
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
memberships: { include: { project: { select: { nummer: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function speichern(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const u = await requireUser();
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: u.id },
|
||||||
|
data: {
|
||||||
|
name: String(formData.get("name")),
|
||||||
|
phone: (formData.get("phone") as string) || null,
|
||||||
|
notifyMode: formData.get("notifyMode") as never,
|
||||||
|
notifyMentions: formData.get("notifyMentions") === "on",
|
||||||
|
notifyAssignments: formData.get("notifyAssignments") === "on",
|
||||||
|
notifyDeadlines: formData.get("notifyDeadlines") === "on",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
revalidatePath("/profil");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function kennwortAendern(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const { redirect } = await import("next/navigation");
|
||||||
|
const u = await requireUser();
|
||||||
|
const datensatz = await prisma.user.findUniqueOrThrow({ where: { id: u.id } });
|
||||||
|
const alt = String(formData.get("alt"));
|
||||||
|
const neu = String(formData.get("neu"));
|
||||||
|
if (!(await verifyPassword(alt, datensatz.passwordHash))) redirect("/profil?fehler=altes-kennwort");
|
||||||
|
if (neu.length < 10) redirect("/profil?fehler=zu-kurz");
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: u.id },
|
||||||
|
data: { passwordHash: await hashPassword(neu), mustChangePw: false },
|
||||||
|
});
|
||||||
|
await prisma.session.deleteMany({ where: { userId: u.id } });
|
||||||
|
redirect("/anmelden");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">{user.name}</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-db-muted">
|
||||||
|
{user.position} · {user.company?.name} · {user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sp.fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">
|
||||||
|
{sp.fehler === "altes-kennwort" ? "Das bisherige Kennwort ist nicht korrekt." : "Das neue Kennwort ist zu kurz (mindestens 10 Zeichen)."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form action={speichern} className="card space-y-4 p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Angaben und Benachrichtigungen</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Name</label>
|
||||||
|
<input name="name" defaultValue={user.name} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Telefon</label>
|
||||||
|
<input name="phone" defaultValue={user.phone ?? ""} className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">E-Mail-Benachrichtigung</label>
|
||||||
|
<select name="notifyMode" defaultValue={user.notifyMode} className="input">
|
||||||
|
<option value="SOFORT">sofort bei jedem Ereignis</option>
|
||||||
|
<option value="TAGESZUSAMMENFASSUNG">einmal täglich als Zusammenfassung</option>
|
||||||
|
<option value="AUS">keine E-Mails – nur im System</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[11px] text-db-muted">
|
||||||
|
Unabhängig davon erscheint jede Benachrichtigung in der Glocke im Kopfbereich.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 text-sm text-slate-300">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="notifyMentions" defaultChecked={user.notifyMentions} /> bei Erwähnung mit @
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="notifyAssignments" defaultChecked={user.notifyAssignments} /> bei Zuweisung
|
||||||
|
einer Aufgabe oder eines Vorgangs
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="notifyDeadlines" defaultChecked={user.notifyDeadlines} /> bei Fristen und
|
||||||
|
fälligen Unterlagen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="btn-primary">Speichern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action={kennwortAendern} className="card space-y-3 p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Kennwort ändern</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Bisheriges Kennwort</label>
|
||||||
|
<input type="password" name="alt" required className="input" autoComplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Neues Kennwort (min. 10 Zeichen)</label>
|
||||||
|
<input type="password" name="neu" required minLength={10} className="input" autoComplete="new-password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-db-muted">Nach der Änderung werden alle Sitzungen beendet.</p>
|
||||||
|
<button className="btn-ghost">Kennwort ändern</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Meine Rollen</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{user.memberships.map((m) => (
|
||||||
|
<tr key={m.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<div className="text-slate-200">{m.project.name}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">{m.project.nummer}</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-right text-sm text-slate-300">{ROLE_LABEL[m.role]}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p className="text-xs text-db-muted">Letzte Anmeldung: {datumZeit(user.lastLoginAt)}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useTransition } from "react";
|
||||||
|
import { Lock } from "lucide-react";
|
||||||
|
import { eintragAbschliessen } from "./actions";
|
||||||
|
|
||||||
|
export function AbschliessenKnopf({ eintragId }: { eintragId: string }) {
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm("Eintrag abschließen? Danach ist er gesperrt und nur noch über einen Nachtrag ergänzbar."))
|
||||||
|
start(() => void eintragAbschliessen(eintragId));
|
||||||
|
}}
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<Lock size={12} /> {pending ? "…" : "abschließen und sperren"}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { eintragAnlegen } from "./actions";
|
||||||
|
|
||||||
|
export function NeuerEintrag({ projectId, naechsteNr }: { projectId: string; naechsteNr: number }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
const heute = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-primary shrink-0">
|
||||||
|
<Plus size={15} /> Tageseintrag
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/60 p-4 backdrop-blur-sm">
|
||||||
|
<form
|
||||||
|
className="card mx-auto max-w-3xl space-y-4 p-5"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await eintragAnlegen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Bautagebuch – Eintrag Nr. {naechsteNr}</h2>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="text-db-muted hover:text-white">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Datum</label>
|
||||||
|
<input type="date" name="datum" defaultValue={heute} required className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Arbeitszeit von</label>
|
||||||
|
<input name="arbeitszeitVon" defaultValue="06:30" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">bis</label>
|
||||||
|
<input name="arbeitszeitBis" defaultValue="16:00" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Temperatur min/max</label>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<input name="tempMin" type="number" placeholder="°C" className="input" />
|
||||||
|
<input name="tempMax" type="number" placeholder="°C" className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Wetter früh</label>
|
||||||
|
<input name="wetterFrueh" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">mittags</label>
|
||||||
|
<input name="wetterMittag" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">abends</label>
|
||||||
|
<input name="wetterAbend" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Sperrpause</label>
|
||||||
|
<input name="sperrpause" placeholder="22:00 – 05:00, Gl. 2" className="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Betra / Baubetriebsplanung</label>
|
||||||
|
<input name="betra" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Sicherungsmaßnahmen</label>
|
||||||
|
<input name="sicherungsmassnahmen" className="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-4">
|
||||||
|
<label className="label mb-1.5 block">Ausgeführte Leistungen</label>
|
||||||
|
<textarea name="leistungen" rows={4} required className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-4">
|
||||||
|
<label className="label mb-1.5 block">Besondere Vorkommnisse</label>
|
||||||
|
<textarea name="besondereVorkommnisse" rows={2} className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Behinderungen</label>
|
||||||
|
<textarea name="behinderungen" rows={2} className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Einweisungen / Belehrungen</label>
|
||||||
|
<textarea name="einweisungen" rows={2} className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary">
|
||||||
|
{pending ? "wird gespeichert …" : "Eintrag speichern"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
|
export async function eintragAnlegen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "bautagebuch.schreiben");
|
||||||
|
|
||||||
|
const datum = new Date(String(formData.get("datum")));
|
||||||
|
const letzter = await prisma.siteDiaryEntry.findFirst({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { lfdNr: "desc" },
|
||||||
|
select: { lfdNr: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const eintrag = await prisma.siteDiaryEntry.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
datum,
|
||||||
|
lfdNr: (letzter?.lfdNr ?? 0) + 1,
|
||||||
|
authorId: user.id,
|
||||||
|
wetterFrueh: (formData.get("wetterFrueh") as string) || null,
|
||||||
|
wetterMittag: (formData.get("wetterMittag") as string) || null,
|
||||||
|
wetterAbend: (formData.get("wetterAbend") as string) || null,
|
||||||
|
tempMin: formData.get("tempMin") ? Number(formData.get("tempMin")) : null,
|
||||||
|
tempMax: formData.get("tempMax") ? Number(formData.get("tempMax")) : null,
|
||||||
|
arbeitszeitVon: (formData.get("arbeitszeitVon") as string) || null,
|
||||||
|
arbeitszeitBis: (formData.get("arbeitszeitBis") as string) || null,
|
||||||
|
betra: (formData.get("betra") as string) || null,
|
||||||
|
sperrpause: (formData.get("sperrpause") as string) || null,
|
||||||
|
leistungen: (formData.get("leistungen") as string) || null,
|
||||||
|
besondereVorkommnisse: (formData.get("besondereVorkommnisse") as string) || null,
|
||||||
|
behinderungen: (formData.get("behinderungen") as string) || null,
|
||||||
|
einweisungen: (formData.get("einweisungen") as string) || null,
|
||||||
|
sicherungsmassnahmen: (formData.get("sicherungsmassnahmen") as string) || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "CREATE", entitaet: "SiteDiaryEntry",
|
||||||
|
entitaetId: eintrag.id, projectId, details: { lfdNr: eintrag.lfdNr },
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${projectId}/bautagebuch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function eintragAbschliessen(eintragId: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } });
|
||||||
|
const roles = await rolesInProject(user.id, eintrag.projectId);
|
||||||
|
assertCan(roles, "bautagebuch.schreiben");
|
||||||
|
if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt.");
|
||||||
|
|
||||||
|
await prisma.siteDiaryEntry.update({
|
||||||
|
where: { id: eintragId },
|
||||||
|
data: { status: "ABGESCHLOSSEN", signedById: user.id, signedAt: new Date(), gesperrtAm: new Date() },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "ABSCHLUSS", entitaet: "SiteDiaryEntry",
|
||||||
|
entitaetId: eintragId, projectId: eintrag.projectId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${eintrag.projectId}/bautagebuch`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { CloudSun, Lock, PenLine, Users } from "lucide-react";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui";
|
||||||
|
import { datum, zahl } from "@/lib/format";
|
||||||
|
import { NeuerEintrag } from "./NeuerEintrag";
|
||||||
|
import { AbschliessenKnopf } from "./AbschliessenKnopf";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { user, roles, optionen } = await projektKontext(id);
|
||||||
|
if (!canInProject(roles, "bautagebuch.lesen", optionen))
|
||||||
|
return <KeinZugriff bereich="Das Bautagebuch" />;
|
||||||
|
|
||||||
|
const darfSchreiben = can(roles, "bautagebuch.schreiben");
|
||||||
|
|
||||||
|
const eintraege = await prisma.siteDiaryEntry.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: {
|
||||||
|
author: { select: { name: true } },
|
||||||
|
signedBy: { select: { name: true } },
|
||||||
|
personal: { include: { company: { select: { shortName: true, name: true } } } },
|
||||||
|
geraete: true,
|
||||||
|
_count: { select: { issues: true, timeSheets: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ datum: "desc" }],
|
||||||
|
take: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
const letzteNr = eintraege[0]?.lfdNr ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Bautagebuch nach Ril 809.0301. Ein abgeschlossener Eintrag wird gesperrt und ist danach nur noch über
|
||||||
|
einen Nachtrag ergänzbar – die ursprüngliche Fassung bleibt erhalten. Personal- und Gerätestunden aus dem
|
||||||
|
Tagebuch dienen der Rechnungsprüfung als Gegenprobe zu den Stundenlohnzetteln.
|
||||||
|
</p>
|
||||||
|
{darfSchreiben && <NeuerEintrag projectId={id} naechsteNr={letzteNr + 1} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eintraege.length === 0 ? (
|
||||||
|
<Leer text="Für dieses Projekt wurde noch kein Bautagebucheintrag erfasst." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{eintraege.map((e) => {
|
||||||
|
const personalStunden = e.personal.reduce((s, p) => s + Number(p.stunden), 0);
|
||||||
|
const personen = e.personal.reduce((s, p) => s + p.anzahl, 0);
|
||||||
|
return (
|
||||||
|
<article key={e.id} className="card overflow-hidden">
|
||||||
|
<header className="flex flex-wrap items-center gap-3 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-xs text-db-muted">Nr. {e.lfdNr}</span>
|
||||||
|
<h3 className="text-sm font-semibold text-white">{datum(e.datum)}</h3>
|
||||||
|
<StatusChip status={e.status} />
|
||||||
|
{e.gesperrtAm && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11px] text-db-muted">
|
||||||
|
<Lock size={11} /> gesperrt am {datum(e.gesperrtAm)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-[11px] text-db-muted">
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<CloudSun size={12} /> {e.wetterFrueh} / {e.wetterMittag} / {e.wetterAbend} · {e.tempMin}–{e.tempMax} °C
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<Users size={12} /> {personen} Pers. · {zahl(personalStunden, 1)} Std.
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
<PenLine size={12} /> {e.author.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-4 p-4 lg:grid-cols-3">
|
||||||
|
<div className="space-y-3 lg:col-span-2">
|
||||||
|
<Feld label="Ausgeführte Leistungen">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-300">{e.leistungen}</p>
|
||||||
|
</Feld>
|
||||||
|
{e.besondereVorkommnisse && (
|
||||||
|
<div className="rounded-md border border-amber-800/50 bg-amber-950/20 p-3">
|
||||||
|
<div className="label text-amber-500">Besondere Vorkommnisse</div>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-amber-100/90">
|
||||||
|
{e.besondereVorkommnisse}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{e.behinderungen && <Feld label="Behinderungen">{e.behinderungen}</Feld>}
|
||||||
|
{e.einweisungen && <Feld label="Einweisungen / Belehrungen">{e.einweisungen}</Feld>}
|
||||||
|
{e.sicherungsmassnahmen && <Feld label="Sicherungsmaßnahmen">{e.sicherungsmassnahmen}</Feld>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Feld label="Arbeitszeit">
|
||||||
|
{e.arbeitszeitVon} – {e.arbeitszeitBis}
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Sperrpause">{e.sperrpause ?? "—"}</Feld>
|
||||||
|
</div>
|
||||||
|
<Feld label="Betra">{e.betra ?? "—"}</Feld>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="label mb-1">Personal</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{e.personal.map((p) => (
|
||||||
|
<tr key={p.id} className="border-t border-db-line/60">
|
||||||
|
<td className="py-1 text-slate-300">
|
||||||
|
{p.company?.shortName ?? p.company?.name ?? "—"}
|
||||||
|
<div className="text-[10px] text-db-muted">{p.taetigkeit}</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-1 text-right tabular-nums text-slate-400">{p.anzahl} P.</td>
|
||||||
|
<td className="py-1 text-right tabular-nums text-slate-400">{zahl(p.stunden, 1)} h</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{e.geraete.filter((g) => g.anzahl > 0).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="label mb-1">Geräte</div>
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{e.geraete
|
||||||
|
.filter((g) => g.anzahl > 0)
|
||||||
|
.map((g) => (
|
||||||
|
<tr key={g.id} className="border-t border-db-line/60">
|
||||||
|
<td className="py-1 text-slate-300">{g.bezeichnung}</td>
|
||||||
|
<td className="py-1 text-right tabular-nums text-slate-400">{g.anzahl}×</td>
|
||||||
|
<td className="py-1 text-right tabular-nums text-slate-400">{zahl(g.stunden, 1)} h</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="flex flex-wrap items-center gap-3 border-t border-db-line px-4 py-2 text-[11px] text-db-muted">
|
||||||
|
{e.signedBy ? (
|
||||||
|
<span>
|
||||||
|
Abgeschlossen von {e.signedBy.name} am {datum(e.signedAt)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-amber-400">noch nicht abgeschlossen</span>
|
||||||
|
)}
|
||||||
|
{e._count.timeSheets > 0 && <span>· {e._count.timeSheets} Stundenlohnzettel verknüpft</span>}
|
||||||
|
{e._count.issues > 0 && <span>· {e._count.issues} Meldung(en) verknüpft</span>}
|
||||||
|
{darfSchreiben && e.status === "ENTWURF" && e.authorId === user.id && (
|
||||||
|
<span className="ml-auto">
|
||||||
|
<AbschliessenKnopf eintragId={e.id} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { requireProjectAccess } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { ROLE_LABEL, ROLE_HINT, PERMISSIONS, can } from "@/lib/rbac";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const RECHTE_ANZEIGE: { schluessel: keyof typeof PERMISSIONS; label: string }[] = [
|
||||||
|
{ schluessel: "dokument.hochladen", label: "Dokumente einstellen" },
|
||||||
|
{ schluessel: "dokument.freigeben", label: "Dokumente freigeben" },
|
||||||
|
{ schluessel: "bautagebuch.schreiben", label: "Bautagebuch führen" },
|
||||||
|
{ schluessel: "stunden.pruefen", label: "Stunden prüfen" },
|
||||||
|
{ schluessel: "rechnung.sachlich", label: "sachlich richtig" },
|
||||||
|
{ schluessel: "rechnung.rechnerisch", label: "rechnerisch richtig" },
|
||||||
|
{ schluessel: "rechnung.freigeben", label: "Zahlung freigeben" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default async function Beteiligte({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
await requireProjectAccess(id);
|
||||||
|
|
||||||
|
const mitglieder = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: { user: { include: { company: true } } },
|
||||||
|
orderBy: [{ role: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const nachFirma = new Map<string, typeof mitglieder>();
|
||||||
|
for (const m of mitglieder) {
|
||||||
|
const k = m.user.company?.name ?? "ohne Firmenzuordnung";
|
||||||
|
if (!nachFirma.has(k)) nachFirma.set(k, []);
|
||||||
|
nachFirma.get(k)!.push(m);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollenImProjekt = [...new Set(mitglieder.map((m) => m.role))] as Role[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Rollen entsprechen den Funktionen aus dem Vorgabeprozess Bauüberwachung. Sie steuern unmittelbar, wer welche
|
||||||
|
Handlung im System vornehmen darf – insbesondere die getrennte Bescheinigung von sachlicher und rechnerischer
|
||||||
|
Richtigkeit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{[...nachFirma.entries()].map(([firma, leute]) => (
|
||||||
|
<section key={firma} className="card overflow-hidden">
|
||||||
|
<header className="border-b border-db-line bg-db-dark/40 px-4 py-2.5">
|
||||||
|
<h2 className="text-sm font-semibold text-white">{firma}</h2>
|
||||||
|
<p className="text-[11px] text-db-muted">
|
||||||
|
{leute[0].user.company?.type.replace(/_/g, " ").toLowerCase() ?? "—"}
|
||||||
|
{leute[0].user.company?.city && ` · ${leute[0].user.company.city}`}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line/60">
|
||||||
|
<th className="th">Person</th>
|
||||||
|
<th className="th">Rolle im Projekt</th>
|
||||||
|
<th className="th">Fachbereiche</th>
|
||||||
|
<th className="th w-40">Kontakt</th>
|
||||||
|
<th className="th w-32 text-right">letzte Anmeldung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{leute.map((m) => (
|
||||||
|
<tr key={m.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<div className="text-slate-100">{m.user.name}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">{m.user.position}</div>
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-300">{ROLE_LABEL[m.role]}</span>
|
||||||
|
<div className="mt-0.5 text-[11px] text-db-muted">{ROLE_HINT[m.role]}</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
<a href={`mailto:${m.user.email}`} className="link">
|
||||||
|
{m.user.email}
|
||||||
|
</a>
|
||||||
|
{m.user.phone && <div>{m.user.phone}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right text-xs tabular-nums text-db-muted">{datum(m.user.lastLoginAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Rechtematrix im Projekt</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Rolle</th>
|
||||||
|
{RECHTE_ANZEIGE.map((r) => (
|
||||||
|
<th key={r.schluessel} className="th text-center">
|
||||||
|
{r.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rollenImProjekt.map((rolle) => (
|
||||||
|
<tr key={rolle} className="row">
|
||||||
|
<td className="td text-slate-200">{ROLE_LABEL[rolle]}</td>
|
||||||
|
{RECHTE_ANZEIGE.map((r) => (
|
||||||
|
<td key={r.schluessel} className="td text-center">
|
||||||
|
{can([rolle], r.schluessel) ? (
|
||||||
|
<span className="text-emerald-400">✓</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-700">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { slotAendern } from "./actions";
|
||||||
|
import { StatusChip } from "@/components/ui";
|
||||||
|
|
||||||
|
const STATUS: { wert: string; label: string }[] = [
|
||||||
|
{ wert: "OFFEN", label: "offen" },
|
||||||
|
{ wert: "ANGEFORDERT", label: "angefordert" },
|
||||||
|
{ wert: "IN_ARBEIT", label: "in Arbeit" },
|
||||||
|
{ wert: "EINGEREICHT", label: "eingereicht" },
|
||||||
|
{ wert: "IN_PRUEFUNG", label: "in Prüfung" },
|
||||||
|
{ wert: "FREIGEGEBEN", label: "freigegeben" },
|
||||||
|
{ wert: "ABGELEHNT", label: "abgelehnt" },
|
||||||
|
{ wert: "ENTFAELLT", label: "entfällt" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SlotZeile({
|
||||||
|
slotId,
|
||||||
|
status,
|
||||||
|
responsibleId,
|
||||||
|
personen,
|
||||||
|
}: {
|
||||||
|
slotId: string;
|
||||||
|
status: string;
|
||||||
|
responsibleId: string | null;
|
||||||
|
personen: { id: string; name: string }[];
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
function speichern(feld: "status" | "responsibleId", wert: string) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("slotId", slotId);
|
||||||
|
fd.set(feld, wert);
|
||||||
|
start(() => {
|
||||||
|
void slotAendern(fd);
|
||||||
|
setOffen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="transition hover:opacity-80" title="Status ändern">
|
||||||
|
<StatusChip status={status} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1.5" onMouseLeave={() => setOffen(false)}>
|
||||||
|
<select
|
||||||
|
defaultValue={status}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => speichern("status", e.target.value)}
|
||||||
|
className="input w-40 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{STATUS.map((s) => (
|
||||||
|
<option key={s.wert} value={s.wert}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
defaultValue={responsibleId ?? ""}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => speichern("responsibleId", e.target.value)}
|
||||||
|
className="input w-40 py-1 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">— nicht zugewiesen —</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
|
import type { SlotStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function slotAendern(formData: FormData) {
|
||||||
|
const slotId = String(formData.get("slotId"));
|
||||||
|
const status = formData.get("status") as SlotStatus | null;
|
||||||
|
const responsibleId = formData.get("responsibleId") as string | null;
|
||||||
|
|
||||||
|
const user = await requireUser();
|
||||||
|
const slot = await prisma.projectDocumentSlot.findUniqueOrThrow({
|
||||||
|
where: { id: slotId },
|
||||||
|
include: { requirement: { select: { titel: true } } },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, slot.projectId);
|
||||||
|
assertCan(roles, "ablage.steuern");
|
||||||
|
|
||||||
|
const daten: Record<string, unknown> = {};
|
||||||
|
if (status) {
|
||||||
|
daten.status = status;
|
||||||
|
if (status === "EINGEREICHT" && !slot.eingangAm) daten.eingangAm = new Date();
|
||||||
|
}
|
||||||
|
if (responsibleId !== null) daten.responsibleId = responsibleId || null;
|
||||||
|
|
||||||
|
await prisma.projectDocumentSlot.update({ where: { id: slotId }, data: daten });
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id,
|
||||||
|
aktion: "UPDATE",
|
||||||
|
entitaet: "ProjectDocumentSlot",
|
||||||
|
entitaetId: slotId,
|
||||||
|
projectId: slot.projectId,
|
||||||
|
details: { von: slot.status, nach: status, verantwortlich: responsibleId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (responsibleId && responsibleId !== slot.responsibleId) {
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: [responsibleId],
|
||||||
|
typ: "ZUWEISUNG",
|
||||||
|
titel: "Neue Zuständigkeit für eine Soll-Unterlage",
|
||||||
|
text: `Ihnen wurde die Unterlage „${slot.requirement.titel}“ zugewiesen.`,
|
||||||
|
link: `/projekte/${slot.projectId}/dokumentation`,
|
||||||
|
projectId: slot.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${slot.projectId}/dokumentation`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui";
|
||||||
|
import { datum, tageBis } from "@/lib/format";
|
||||||
|
import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen";
|
||||||
|
import { SlotZeile } from "./SlotZeile";
|
||||||
|
import { Download, Filter } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const AKTE_LABEL: Record<string, string> = {
|
||||||
|
P: "Planungsakte",
|
||||||
|
B: "Baudokumentation / Bauakten",
|
||||||
|
K: "Kaufmännische Akte",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Dokumentation({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ akte?: string; status?: string; q?: string; nur?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { roles, projekt, optionen } = await projektKontext(id);
|
||||||
|
if (!canInProject(roles, "ablage.lesen", optionen))
|
||||||
|
return <KeinZugriff bereich="Der Dokumentationsstand" />;
|
||||||
|
|
||||||
|
const darfSteuern = can(roles, "ablage.steuern");
|
||||||
|
const akte = sp.akte ?? "P";
|
||||||
|
|
||||||
|
const alle = await prisma.projectDocumentSlot.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: {
|
||||||
|
requirement: { include: { node: true } },
|
||||||
|
responsible: { select: { id: true, name: true } },
|
||||||
|
_count: { select: { documents: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const gesamt = alle.length;
|
||||||
|
const fertig = alle.filter((s) => s.status === "FREIGEGEBEN" || s.status === "ENTFAELLT").length;
|
||||||
|
const ueberfaellig = alle.filter(
|
||||||
|
(s) => s.status !== "FREIGEGEBEN" && s.status !== "ENTFAELLT" && s.frist && s.frist < new Date(),
|
||||||
|
).length;
|
||||||
|
const inPruefung = alle.filter((s) => s.status === "IN_PRUEFUNG" || s.status === "EINGEREICHT").length;
|
||||||
|
|
||||||
|
// Filter anwenden
|
||||||
|
let gefiltert = alle.filter((s) => s.requirement.node.akte === akte);
|
||||||
|
if (sp.status) gefiltert = gefiltert.filter((s) => s.status === sp.status);
|
||||||
|
if (sp.nur === "offen") gefiltert = gefiltert.filter((s) => s.status !== "FREIGEGEBEN" && s.status !== "ENTFAELLT");
|
||||||
|
if (sp.nur === "faellig")
|
||||||
|
gefiltert = gefiltert.filter(
|
||||||
|
(s) => s.status !== "FREIGEGEBEN" && s.status !== "ENTFAELLT" && s.frist && (tageBis(s.frist) ?? 999) < 45,
|
||||||
|
);
|
||||||
|
if (sp.q) {
|
||||||
|
const q = sp.q.toLowerCase();
|
||||||
|
gefiltert = gefiltert.filter(
|
||||||
|
(s) =>
|
||||||
|
s.requirement.titel.toLowerCase().includes(q) ||
|
||||||
|
(s.requirement.ril ?? "").toLowerCase().includes(q) ||
|
||||||
|
s.requirement.node.titel.toLowerCase().includes(q) ||
|
||||||
|
s.requirement.node.code.toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// nach Ordnerknoten gruppieren
|
||||||
|
const gruppen = new Map<string, typeof gefiltert>();
|
||||||
|
for (const s of gefiltert) {
|
||||||
|
const k = s.requirement.node.code;
|
||||||
|
if (!gruppen.has(k)) gruppen.set(k, []);
|
||||||
|
gruppen.get(k)!.push(s);
|
||||||
|
}
|
||||||
|
const knoten = await prisma.filingNode.findMany({
|
||||||
|
where: { code: { in: [...gruppen.keys()] } },
|
||||||
|
orderBy: { sortIndex: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mitglieder = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
const personen = [...new Map(mitglieder.map((m) => [m.user.id, m.user])).values()];
|
||||||
|
|
||||||
|
const filterLink = (o: Record<string, string | undefined>) => {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
const merged = { akte, status: sp.status, q: sp.q, nur: sp.nur, ...o };
|
||||||
|
for (const [k, v] of Object.entries(merged)) if (v) p.set(k, v);
|
||||||
|
return `?${p.toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel
|
||||||
|
titel="Dokumentationsgrad"
|
||||||
|
wert={`${gesamt ? Math.round((fertig / gesamt) * 100) : 0} %`}
|
||||||
|
hinweis={`${fertig} von ${gesamt} Soll-Unterlagen`}
|
||||||
|
ton={fertig / Math.max(gesamt, 1) > 0.66 ? "gut" : "warnung"}
|
||||||
|
/>
|
||||||
|
<Kachel titel="In Prüfung / eingereicht" wert={inPruefung} hinweis="warten auf Freigabe" />
|
||||||
|
<Kachel titel="Überfällig" wert={ueberfaellig} hinweis="Frist überschritten" ton={ueberfaellig ? "kritisch" : "gut"} />
|
||||||
|
<div className="card-pad">
|
||||||
|
<div className="label">Herkunft des Katalogs</div>
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-slate-300">
|
||||||
|
EIU-Ablagestruktur, gefiltert auf die Fachbereiche dieses Projekts. Fristen ergeben sich aus dem
|
||||||
|
Übergabezeitpunkt je Unterlage und den Projektterminen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filterleiste */}
|
||||||
|
<div className="card flex flex-wrap items-center gap-2 p-3">
|
||||||
|
<div className="flex rounded-md border border-db-line">
|
||||||
|
{(["P", "B", "K"] as const).map((k) => (
|
||||||
|
<Link
|
||||||
|
key={k}
|
||||||
|
href={filterLink({ akte: k })}
|
||||||
|
className={`px-3 py-1.5 text-xs transition first:rounded-l-md last:rounded-r-md ${
|
||||||
|
akte === k ? "bg-db-line font-medium text-white" : "text-db-muted hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{AKTE_LABEL[k]}
|
||||||
|
<span className="ml-1.5 tabular-nums opacity-60">
|
||||||
|
{alle.filter((s) => s.requirement.node.akte === k).length}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="flex items-center gap-2" action="">
|
||||||
|
<input type="hidden" name="akte" value={akte} />
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={sp.q ?? ""}
|
||||||
|
placeholder="Unterlage, Richtlinie oder Gliederungsnummer …"
|
||||||
|
className="input w-72 py-1.5 text-xs"
|
||||||
|
/>
|
||||||
|
<button className="btn-ghost btn-sm" type="submit">
|
||||||
|
<Filter size={13} /> Filtern
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="ml-auto flex gap-1.5">
|
||||||
|
<Link
|
||||||
|
href={filterLink({ nur: sp.nur === "offen" ? undefined : "offen", status: undefined })}
|
||||||
|
className={`btn btn-sm border ${sp.nur === "offen" ? "border-db-cyan bg-db-cyan/15 text-db-cyan" : "border-db-line text-db-muted hover:text-slate-200"}`}
|
||||||
|
>
|
||||||
|
nur offene
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href={filterLink({ nur: sp.nur === "faellig" ? undefined : "faellig", status: undefined })}
|
||||||
|
className={`btn btn-sm border ${sp.nur === "faellig" ? "border-amber-600 bg-amber-900/25 text-amber-300" : "border-db-line text-db-muted hover:text-slate-200"}`}
|
||||||
|
>
|
||||||
|
demnächst fällig
|
||||||
|
</Link>
|
||||||
|
<a href={`/api/projekte/${id}/dokumentation.csv?akte=${akte}`} className="btn-ghost btn-sm">
|
||||||
|
<Download size={13} /> CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-db-muted">
|
||||||
|
{gefiltert.length} Positionen in {knoten.length} Ordnern
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Baum */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{knoten.map((k) => {
|
||||||
|
const zeilen = gruppen.get(k.code)!;
|
||||||
|
const okay = zeilen.filter((z) => z.status === "FREIGEGEBEN" || z.status === "ENTFAELLT").length;
|
||||||
|
const proz = Math.round((okay / zeilen.length) * 100);
|
||||||
|
return (
|
||||||
|
<section key={k.code} className="card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-3 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
|
||||||
|
<span className="rounded bg-db-line px-1.5 py-0.5 font-mono text-[11px] text-slate-300">{k.code}</span>
|
||||||
|
<h3 className="text-sm font-medium text-white">{k.titel}</h3>
|
||||||
|
{k.ril && <span className="text-[11px] text-db-muted">Ril {k.ril}</span>}
|
||||||
|
<div className="ml-auto flex w-40 items-center gap-2">
|
||||||
|
<Fortschritt wert={proz} ton={proz > 66 ? "gut" : proz > 33 ? "warnung" : "kritisch"} />
|
||||||
|
<span className="w-14 shrink-0 text-right text-[11px] tabular-nums text-db-muted">
|
||||||
|
{okay}/{zeilen.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line/60">
|
||||||
|
<th className="th w-6"></th>
|
||||||
|
<th className="th">Unterlage</th>
|
||||||
|
<th className="th w-40">Übergabe / Frist</th>
|
||||||
|
<th className="th w-36">Verantwortlich</th>
|
||||||
|
<th className="th w-32">Aufbewahrung</th>
|
||||||
|
<th className="th w-44 text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((s) => {
|
||||||
|
const erledigt = s.status === "FREIGEGEBEN" || s.status === "ENTFAELLT";
|
||||||
|
const ampel = fristAmpel(s.frist, erledigt);
|
||||||
|
const aufb = berechneAufbewahrung(
|
||||||
|
s.requirement.aufbewahrungJahre,
|
||||||
|
s.requirement.aufbewahrungNutzungsdauer,
|
||||||
|
projekt.bauschlussmeldung,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<tr key={s.id} className="row align-top">
|
||||||
|
<td className="td pt-3">
|
||||||
|
<AmpelPunkt ampel={ampel} />
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<div className={s.requirement.istUnterpunkt ? "pl-4 text-slate-300" : "text-slate-100"}>
|
||||||
|
{s.requirement.istUnterpunkt && <span className="mr-1 text-db-muted">•</span>}
|
||||||
|
{s.requirement.titel}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-center gap-x-2 text-[11px] text-db-muted">
|
||||||
|
{s.requirement.ril && <span>Ril {s.requirement.ril}</span>}
|
||||||
|
{s.requirement.medien.length > 0 && <span>{s.requirement.medien.join(" / ")}</span>}
|
||||||
|
{s._count.documents > 0 && (
|
||||||
|
<Link href={`/projekte/${id}/dokumente?slot=${s.id}`} className="link">
|
||||||
|
{s._count.documents} Datei(en)
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs">
|
||||||
|
<div className="text-slate-300">
|
||||||
|
{s.requirement.uebergabe.map((u) => UEBERGABE_LABEL[u]).join(", ") || "—"}
|
||||||
|
</div>
|
||||||
|
<div className={`tabular-nums ${ampel === "ROT" ? "text-red-400" : ampel === "GELB" ? "text-amber-400" : "text-db-muted"}`}>
|
||||||
|
{s.frist ? datum(s.frist) : "kein Termin hinterlegt"}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-300">{s.responsible?.name ?? <span className="text-db-muted">nicht zugewiesen</span>}</td>
|
||||||
|
<td className="td text-xs text-db-muted">
|
||||||
|
{aufb.hinweis}
|
||||||
|
{aufb.bis && <div className="tabular-nums">bis {datum(aufb.bis)}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
{darfSteuern ? (
|
||||||
|
<SlotZeile
|
||||||
|
slotId={s.id}
|
||||||
|
status={s.status}
|
||||||
|
responsibleId={s.responsibleId}
|
||||||
|
personen={personen}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<StatusChip status={s.status} />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import { dokumentStatus } from "./actions";
|
||||||
|
|
||||||
|
export function FreigabeKnopf({ documentId }: { documentId: string }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
function setzen(status: string, kommentar: string) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("documentId", documentId);
|
||||||
|
fd.set("status", status);
|
||||||
|
fd.set("kommentar", kommentar);
|
||||||
|
start(() => {
|
||||||
|
void dokumentStatus(fd);
|
||||||
|
setOffen(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-ghost btn-sm" title="Prüfen und freigeben">
|
||||||
|
<Check size={13} /> Prüfen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-end gap-1.5">
|
||||||
|
<input
|
||||||
|
id={`k-${documentId}`}
|
||||||
|
placeholder="Vermerk (optional)"
|
||||||
|
className="input w-56 py-1 text-xs"
|
||||||
|
disabled={pending}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
setzen("FREIGEGEBEN", (document.getElementById(`k-${documentId}`) as HTMLInputElement)?.value ?? "")
|
||||||
|
}
|
||||||
|
className="btn btn-sm border border-emerald-700 bg-emerald-900/30 text-emerald-300 hover:bg-emerald-900/50"
|
||||||
|
>
|
||||||
|
<Check size={12} /> freigeben
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() =>
|
||||||
|
setzen("ABGELEHNT", (document.getElementById(`k-${documentId}`) as HTMLInputElement)?.value ?? "")
|
||||||
|
}
|
||||||
|
className="btn btn-sm border border-red-800 bg-red-900/30 text-red-300 hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
<X size={12} /> ablehnen
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setOffen(false)} className="btn-ghost btn-sm">
|
||||||
|
zurück
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Upload, X } from "lucide-react";
|
||||||
|
import { dokumentHochladen } from "./actions";
|
||||||
|
|
||||||
|
export function UploadPanel({
|
||||||
|
projectId,
|
||||||
|
knoten,
|
||||||
|
slot,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
knoten: { code: string; titel: string }[];
|
||||||
|
slot: { id: string; titel: string; nodeCode: string } | null;
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(!!slot);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen) {
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-primary">
|
||||||
|
<Upload size={15} /> Dokument einstellen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="card space-y-4 p-4"
|
||||||
|
action={(fd) => {
|
||||||
|
setFehler(null);
|
||||||
|
start(async () => {
|
||||||
|
try {
|
||||||
|
await dokumentHochladen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
} catch (e) {
|
||||||
|
setFehler(e instanceof Error ? e.message : "Der Upload ist fehlgeschlagen.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
{slot && <input type="hidden" name="slotId" value={slot.id} />}
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Dokument einstellen</h2>
|
||||||
|
{slot && (
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
wird der Soll-Unterlage „{slot.titel}“ ({slot.nodeCode}) zugeordnet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="text-db-muted hover:text-white">
|
||||||
|
<X size={17} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Datei</label>
|
||||||
|
<input type="file" name="datei" required className="input file:mr-3 file:rounded file:border-0 file:bg-db-line file:px-3 file:py-1 file:text-xs file:text-slate-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Titel</label>
|
||||||
|
<input name="titel" defaultValue={slot?.titel ?? ""} placeholder="wird sonst aus dem Dateinamen übernommen" className="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!slot && (
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Ablageort (EIU-Ablagestruktur)</label>
|
||||||
|
<select name="nodeCode" className="input">
|
||||||
|
<option value="">nicht zuordnen</option>
|
||||||
|
{knoten.map((k) => (
|
||||||
|
<option key={k.code} value={k.code}>
|
||||||
|
{k.code} · {k.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Dokumentnummer</label>
|
||||||
|
<input name="dokumentNr" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Plannummer / Stand</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input name="planNr" placeholder="Plan-Nr." className="input" />
|
||||||
|
<input name="planStand" placeholder="Rev." className="input w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Fachbereich</label>
|
||||||
|
<input name="fachbereich" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Vertraulichkeit</label>
|
||||||
|
<select name="vertraulich" className="input">
|
||||||
|
<option value="OFFEN">offen – alle Projektbeteiligten</option>
|
||||||
|
<option value="INTERN">intern – nur Bauherrenseite</option>
|
||||||
|
<option value="VERTRAULICH">vertraulich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Schlagworte (durch Komma getrennt)</label>
|
||||||
|
<input name="schlagworte" placeholder="EÜ Ilmenau, Statik, Prüfbericht" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Bemerkung</label>
|
||||||
|
<textarea name="beschreibung" rows={2} className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary">
|
||||||
|
<Upload size={15} /> {pending ? "wird übertragen …" : "Hochladen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan, assertCanInProject, can } from "@/lib/rbac";
|
||||||
|
import { optionenAus } from "@/lib/projekte";
|
||||||
|
import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage";
|
||||||
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
|
import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function dokumentHochladen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
|
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
|
||||||
|
const datei = formData.get("datei") as File | null;
|
||||||
|
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
||||||
|
if (datei.size > maxUploadBytes())
|
||||||
|
throw new Error(`Die Datei ist größer als ${process.env.MAX_UPLOAD_MB ?? 200} MB.`);
|
||||||
|
if (!mimeErlaubt(datei.type || "application/octet-stream"))
|
||||||
|
throw new Error(`Dateityp ${datei.type} ist nicht zugelassen.`);
|
||||||
|
|
||||||
|
const buffer = Buffer.from(await datei.arrayBuffer());
|
||||||
|
const { pfad, sha256, groesse } = await speichereDatei(projekt.nummer, datei.name, buffer);
|
||||||
|
|
||||||
|
const slotId = (formData.get("slotId") as string) || null;
|
||||||
|
const slot = slotId
|
||||||
|
? await prisma.projectDocumentSlot.findUnique({
|
||||||
|
where: { id: slotId },
|
||||||
|
include: { requirement: { select: { titel: true, nodeCode: true } } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const titel = String(formData.get("titel") || slot?.requirement.titel || datei.name);
|
||||||
|
const nodeCode = (formData.get("nodeCode") as string) || slot?.requirement.nodeCode || null;
|
||||||
|
|
||||||
|
const dokument = await prisma.document.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
titel,
|
||||||
|
nodeCode,
|
||||||
|
slotId,
|
||||||
|
dokumentNr: (formData.get("dokumentNr") as string) || null,
|
||||||
|
fachbereich: (formData.get("fachbereich") as string) || null,
|
||||||
|
beschreibung: (formData.get("beschreibung") as string) || null,
|
||||||
|
planNr: (formData.get("planNr") as string) || null,
|
||||||
|
planStand: (formData.get("planStand") as string) || null,
|
||||||
|
vertraulich: ((formData.get("vertraulich") as Vertraulichkeit) || "OFFEN") as Vertraulichkeit,
|
||||||
|
status: "EINGEREICHT",
|
||||||
|
createdById: user.id,
|
||||||
|
schlagworte: String(formData.get("schlagworte") ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
versions: {
|
||||||
|
create: {
|
||||||
|
version: 1,
|
||||||
|
dateiname: datei.name,
|
||||||
|
pfad,
|
||||||
|
mimeType: datei.type || "application/octet-stream",
|
||||||
|
groesse,
|
||||||
|
sha256,
|
||||||
|
uploadedById: user.id,
|
||||||
|
kommentar: (formData.get("kommentar") as string) || null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (slot && slot.status !== "FREIGEGEBEN") {
|
||||||
|
await prisma.projectDocumentSlot.update({
|
||||||
|
where: { id: slot.id },
|
||||||
|
data: { status: "EINGEREICHT", eingangAm: slot.eingangAm ?? new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "CREATE", entitaet: "Document",
|
||||||
|
entitaetId: dokument.id, projectId, details: { titel, dateiname: datei.name },
|
||||||
|
});
|
||||||
|
|
||||||
|
const pruefer = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId, role: { in: ["BAUUEBERWACHER", "PROJEKTLEITER"] } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: pruefer.map((p) => p.userId),
|
||||||
|
typ: "DOKUMENT_NEU",
|
||||||
|
titel: `Neues Dokument: ${titel}`,
|
||||||
|
text: `${user.name} hat „${datei.name}“ eingestellt${nodeCode ? ` (Ablage ${nodeCode})` : ""}.`,
|
||||||
|
link: `/projekte/${projectId}/dokumente`,
|
||||||
|
projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}/dokumente`);
|
||||||
|
revalidatePath(`/projekte/${projectId}/dokumentation`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function neueVersion(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const documentId = String(formData.get("documentId"));
|
||||||
|
const dokument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: documentId },
|
||||||
|
include: { project: { select: { nummer: true } }, versions: { orderBy: { version: "desc" }, take: 1 } },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, dokument.projectId);
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: dokument.projectId } });
|
||||||
|
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
|
||||||
|
|
||||||
|
const datei = formData.get("datei") as File | null;
|
||||||
|
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
||||||
|
const buffer = Buffer.from(await datei.arrayBuffer());
|
||||||
|
const { pfad, sha256, groesse } = await speichereDatei(dokument.project.nummer, datei.name, buffer);
|
||||||
|
|
||||||
|
await prisma.documentVersion.create({
|
||||||
|
data: {
|
||||||
|
documentId,
|
||||||
|
version: (dokument.versions[0]?.version ?? 0) + 1,
|
||||||
|
dateiname: datei.name,
|
||||||
|
pfad,
|
||||||
|
mimeType: datei.type || "application/octet-stream",
|
||||||
|
groesse,
|
||||||
|
sha256,
|
||||||
|
uploadedById: user.id,
|
||||||
|
kommentar: (formData.get("kommentar") as string) || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.document.update({ where: { id: documentId }, data: { status: "EINGEREICHT" } });
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "UPDATE", entitaet: "DocumentVersion",
|
||||||
|
entitaetId: documentId, projectId: dokument.projectId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dokumentStatus(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const documentId = String(formData.get("documentId"));
|
||||||
|
const status = String(formData.get("status")) as DocumentStatus;
|
||||||
|
const kommentar = (formData.get("kommentar") as string) || null;
|
||||||
|
|
||||||
|
const dokument = await prisma.document.findUniqueOrThrow({
|
||||||
|
where: { id: documentId },
|
||||||
|
select: { projectId: true, titel: true, slotId: true, createdById: true },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, dokument.projectId);
|
||||||
|
assertCan(roles, "dokument.freigeben");
|
||||||
|
|
||||||
|
await prisma.document.update({ where: { id: documentId }, data: { status } });
|
||||||
|
await prisma.documentReview.create({
|
||||||
|
data: {
|
||||||
|
documentId,
|
||||||
|
art: "FREIGABE",
|
||||||
|
ergebnis: status === "FREIGEGEBEN" ? "FREIGEGEBEN" : status === "ABGELEHNT" ? "ABGELEHNT" : "OFFEN",
|
||||||
|
reviewerId: user.id,
|
||||||
|
erledigtAm: new Date(),
|
||||||
|
kommentar,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dokument.slotId) {
|
||||||
|
await prisma.projectDocumentSlot.update({
|
||||||
|
where: { id: dokument.slotId },
|
||||||
|
data: { status: status === "FREIGEGEBEN" ? "FREIGEGEBEN" : status === "ABGELEHNT" ? "ABGELEHNT" : "IN_PRUEFUNG" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "FREIGABE", entitaet: "Document",
|
||||||
|
entitaetId: documentId, projectId: dokument.projectId, details: { status, kommentar },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dokument.createdById) {
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: [dokument.createdById],
|
||||||
|
typ: "FREIGABE_ANGEFORDERT",
|
||||||
|
titel: `Dokument ${status === "FREIGEGEBEN" ? "freigegeben" : "geprüft"}: ${dokument.titel}`,
|
||||||
|
text: kommentar ?? `Status: ${status}`,
|
||||||
|
link: `/projekte/${dokument.projectId}/dokumente`,
|
||||||
|
projectId: dokument.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
|
||||||
|
revalidatePath(`/projekte/${dokument.projectId}/dokumentation`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function darfFreigeben(projectId: string, userId: string) {
|
||||||
|
return can(await rolesInProject(userId, projectId), "dokument.freigeben");
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download, FileText, Upload, ShieldAlert } from "lucide-react";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Leer } from "@/components/ui";
|
||||||
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
|
import { UploadPanel } from "./UploadPanel";
|
||||||
|
import { FreigabeKnopf } from "./FreigabeKnopf";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const kb = (b: number) => (b > 1048576 ? `${(b / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(b / 1024))} kB`);
|
||||||
|
|
||||||
|
export default async function Dokumente({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ q?: string; slot?: string; node?: string; status?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
|
const darfHochladen = canInProject(roles, "dokument.hochladen", optionen);
|
||||||
|
const darfFreigeben = can(roles, "dokument.freigeben");
|
||||||
|
const nurExtern = nurEigeneFirma;
|
||||||
|
|
||||||
|
const dokumente = await prisma.document.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
...(sp.slot ? { slotId: sp.slot } : {}),
|
||||||
|
...(sp.node ? { nodeCode: { startsWith: sp.node } } : {}),
|
||||||
|
...(sp.status ? { status: sp.status as never } : {}),
|
||||||
|
...(sp.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ titel: { contains: sp.q, mode: "insensitive" as const } },
|
||||||
|
{ dokumentNr: { contains: sp.q, mode: "insensitive" as const } },
|
||||||
|
{ planNr: { contains: sp.q, mode: "insensitive" as const } },
|
||||||
|
{ beschreibung: { contains: sp.q, mode: "insensitive" as const } },
|
||||||
|
{ schlagworte: { has: sp.q } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
// Externe sehen keine internen oder vertraulichen Unterlagen
|
||||||
|
...(nurExtern ? { vertraulich: "OFFEN" as const } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
node: { select: { code: true, titel: true } },
|
||||||
|
versions: { orderBy: { version: "desc" }, include: { uploadedBy: { select: { name: true } } } },
|
||||||
|
slot: { include: { requirement: { select: { titel: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 300,
|
||||||
|
});
|
||||||
|
|
||||||
|
const knoten = await prisma.filingNode.findMany({ where: { level: { lte: 2 } }, orderBy: { sortIndex: "asc" } });
|
||||||
|
const slot = sp.slot
|
||||||
|
? await prisma.projectDocumentSlot.findUnique({
|
||||||
|
where: { id: sp.slot },
|
||||||
|
include: { requirement: { select: { titel: true, nodeCode: true } } },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{darfHochladen && (
|
||||||
|
<UploadPanel
|
||||||
|
projectId={id}
|
||||||
|
knoten={knoten.map((k) => ({ code: k.code, titel: k.titel }))}
|
||||||
|
slot={slot ? { id: slot.id, titel: slot.requirement.titel, nodeCode: slot.requirement.nodeCode } : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="card flex flex-wrap items-center gap-2 p-3" action="">
|
||||||
|
<input
|
||||||
|
name="q"
|
||||||
|
defaultValue={sp.q ?? ""}
|
||||||
|
placeholder="Titel, Dokument-/Plannummer, Schlagwort …"
|
||||||
|
className="input w-80 py-1.5 text-xs"
|
||||||
|
/>
|
||||||
|
<select name="node" defaultValue={sp.node ?? ""} className="input w-72 py-1.5 text-xs">
|
||||||
|
<option value="">alle Ordner der Ablagestruktur</option>
|
||||||
|
{knoten.map((k) => (
|
||||||
|
<option key={k.code} value={k.code}>
|
||||||
|
{k.code} · {k.titel}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select name="status" defaultValue={sp.status ?? ""} className="input w-44 py-1.5 text-xs">
|
||||||
|
<option value="">jeder Status</option>
|
||||||
|
{["ENTWURF", "EINGEREICHT", "IN_PRUEFUNG", "FREIGEGEBEN", "ABGELEHNT", "ARCHIVIERT"].map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button className="btn-ghost btn-sm" type="submit">
|
||||||
|
Suchen
|
||||||
|
</button>
|
||||||
|
{(sp.q || sp.node || sp.status || sp.slot) && (
|
||||||
|
<Link href={`/projekte/${id}/dokumente`} className="text-xs text-db-muted hover:text-slate-200">
|
||||||
|
Filter zurücksetzen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-xs text-db-muted">{dokumente.length} Dokumente</span>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{dokumente.length === 0 ? (
|
||||||
|
<Leer
|
||||||
|
text={
|
||||||
|
darfHochladen
|
||||||
|
? "Noch keine Dokumente abgelegt. Über das Feld oben können Sie die erste Datei einstellen."
|
||||||
|
: "Keine Dokumente vorhanden."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Dokument</th>
|
||||||
|
<th className="th w-56">Ablage</th>
|
||||||
|
<th className="th w-44">Letzte Version</th>
|
||||||
|
<th className="th w-32 text-right">Status</th>
|
||||||
|
<th className="th w-40 text-right">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{dokumente.map((d) => {
|
||||||
|
const v = d.versions[0];
|
||||||
|
return (
|
||||||
|
<tr key={d.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<FileText size={15} className="mt-0.5 shrink-0 text-db-muted" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-slate-100">{d.titel}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{d.dokumentNr && `${d.dokumentNr} · `}
|
||||||
|
{d.planNr && `Plan ${d.planNr}${d.planStand ? ` (${d.planStand})` : ""} · `}
|
||||||
|
{v && `${v.dateiname} · ${kb(v.groesse)}`}
|
||||||
|
{d.vertraulich !== "OFFEN" && (
|
||||||
|
<span className="ml-1.5 inline-flex items-center gap-1 text-amber-400">
|
||||||
|
<ShieldAlert size={11} /> {d.vertraulich.toLowerCase()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{d.schlagworte.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{d.schlagworte.map((s) => (
|
||||||
|
<span key={s} className="chip border-db-line bg-db-dark text-[10px] text-slate-400">
|
||||||
|
{s}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs">
|
||||||
|
{d.node ? (
|
||||||
|
<>
|
||||||
|
<span className="rounded bg-db-line px-1 py-0.5 font-mono text-[10px] text-slate-300">
|
||||||
|
{d.node.code}
|
||||||
|
</span>
|
||||||
|
<div className="mt-0.5 text-db-muted">{d.node.titel}</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-db-muted">nicht zugeordnet</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-db-muted">
|
||||||
|
{v ? (
|
||||||
|
<>
|
||||||
|
<div className="text-slate-300">
|
||||||
|
Version {v.version} · {datum(v.uploadedAt)}
|
||||||
|
</div>
|
||||||
|
<div>{v.uploadedBy?.name}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[10px]" title={`SHA-256 ${v.sha256}`}>
|
||||||
|
{v.sha256.slice(0, 16)}…
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<StatusChip status={d.status} />
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
{v && (
|
||||||
|
<a href={`/api/dateien/${v.id}`} className="btn-ghost btn-sm" title="Herunterladen">
|
||||||
|
<Download size={13} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{darfFreigeben && d.status !== "FREIGEGEBEN" && <FreigabeKnopf documentId={d.id} />}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-db-muted">
|
||||||
|
Angemeldet als {user.name}. Jeder Download wird protokolliert; Dateien werden mit SHA-256-Prüfsumme
|
||||||
|
gespeichert, sodass nachträgliche Veränderungen erkennbar sind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Check, Eye, EyeOff } from "lucide-react";
|
||||||
|
import { einstellungenSpeichern } from "./actions";
|
||||||
|
import type { ProjektOptionen } from "@/lib/rbac";
|
||||||
|
|
||||||
|
type OptionInfo = { feld: keyof ProjektOptionen; titel: string; hinweis: string; standard: boolean };
|
||||||
|
type Werte = ProjektOptionen & { freigabeTrotzBefund: boolean };
|
||||||
|
|
||||||
|
export function EinstellungenFormular({
|
||||||
|
projectId,
|
||||||
|
optionen,
|
||||||
|
werte,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
optionen: OptionInfo[];
|
||||||
|
werte: Werte;
|
||||||
|
}) {
|
||||||
|
const [aktuell, setAktuell] = useState<Werte>(werte);
|
||||||
|
const [gespeichert, setGespeichert] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const geaendert = (Object.keys(werte) as (keyof Werte)[]).some((k) => werte[k] !== aktuell[k]);
|
||||||
|
const sichtbar = optionen.filter((o) => aktuell[o.feld]).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="card space-y-4 p-4"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await einstellungenSpeichern(fd);
|
||||||
|
setGespeichert(true);
|
||||||
|
setTimeout(() => setGespeichert(false), 2500);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Was Auftragnehmer sehen dürfen</h2>
|
||||||
|
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Baufirmen sehen ausschließlich Daten der eigenen Firma – das ist fest verdrahtet und
|
||||||
|
durch keinen Schalter aufhebbar. Hier entscheiden Sie nur, welche Bereiche ihnen
|
||||||
|
überhaupt angezeigt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-400">
|
||||||
|
{sichtbar} von {optionen.length} Bereichen freigegeben
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{optionen.map((o) => {
|
||||||
|
const an = aktuell[o.feld];
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={o.feld}
|
||||||
|
className={`flex cursor-pointer items-start gap-3 rounded-md border p-3 transition ${
|
||||||
|
an ? "border-db-cyan/50 bg-db-cyan/5" : "border-db-line hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name={o.feld}
|
||||||
|
checked={an}
|
||||||
|
onChange={(e) => setAktuell({ ...aktuell, [o.feld]: e.target.checked })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex items-center gap-2 text-sm font-medium text-slate-100">
|
||||||
|
{an ? <Eye size={13} className="text-db-cyan" /> : <EyeOff size={13} className="text-db-muted" />}
|
||||||
|
{o.titel}
|
||||||
|
{o.standard !== an && (
|
||||||
|
<span className="chip border-db-line bg-db-dark text-[10px] text-db-muted">
|
||||||
|
abweichend von der Voreinstellung
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block max-w-2xl text-xs leading-relaxed text-db-muted">{o.hinweis}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-db-line pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Ablauf der Rechnungsprüfung</h3>
|
||||||
|
<label
|
||||||
|
className={`mt-2 flex cursor-pointer items-start gap-3 rounded-md border p-3 transition ${
|
||||||
|
aktuell.freigabeTrotzBefund ? "border-amber-800/50 bg-amber-950/10" : "border-db-line hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="freigabeTrotzBefund"
|
||||||
|
checked={aktuell.freigabeTrotzBefund}
|
||||||
|
onChange={(e) => setAktuell({ ...aktuell, freigabeTrotzBefund: e.target.checked })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-slate-100">
|
||||||
|
Sachliche Bescheinigung trotz kritischer Prüfbefunde zulassen
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block max-w-2xl text-xs leading-relaxed text-db-muted">
|
||||||
|
{aktuell.freigabeTrotzBefund
|
||||||
|
? "Die Bauüberwachung kann eine Rechnung auch mit offenen kritischen Befunden bescheinigen – nur mit Begründung, und der Vorgang wird protokolliert."
|
||||||
|
: "Strenger Ablauf: Alle kritischen Befunde müssen als geklärt vermerkt sein, bevor bescheinigt werden kann."}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="submit" disabled={pending || !geaendert} className="btn-primary">
|
||||||
|
{pending ? "wird gespeichert …" : "Einstellungen speichern"}
|
||||||
|
</button>
|
||||||
|
{gespeichert && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-emerald-400">
|
||||||
|
<Check size={14} /> gespeichert
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{geaendert && !pending && !gespeichert && (
|
||||||
|
<span className="text-xs text-amber-400">nicht gespeicherte Änderungen</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Plus, Trash2, UserPlus } from "lucide-react";
|
||||||
|
import { mitgliedHinzufuegen, mitgliedEntfernen } from "./actions";
|
||||||
|
|
||||||
|
type Mitglied = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
position: string | null;
|
||||||
|
firma: string | null;
|
||||||
|
role: string;
|
||||||
|
roleLabel: string;
|
||||||
|
fachbereiche: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Mitglieder({
|
||||||
|
projectId,
|
||||||
|
mitglieder,
|
||||||
|
personen,
|
||||||
|
rollen,
|
||||||
|
fachbereicheAuswahl,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
mitglieder: Mitglied[];
|
||||||
|
personen: { id: string; name: string; position: string | null; firma: string | null }[];
|
||||||
|
rollen: { wert: string; label: string; hinweis: string }[];
|
||||||
|
fachbereicheAuswahl: string[];
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [rolle, setRolle] = useState("LESER");
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const hinweis = rollen.find((r) => r.wert === rolle)?.hinweis;
|
||||||
|
const braucht = rolle === "FACHBAUUEBERWACHER";
|
||||||
|
|
||||||
|
const lauf = (fn: () => Promise<unknown>) =>
|
||||||
|
start(async () => {
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
setOffen(false);
|
||||||
|
} catch (e) {
|
||||||
|
setFehler(e instanceof Error ? e.message : "Die Änderung war nicht möglich.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<header className="flex items-center justify-between border-b border-db-line px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Beteiligte</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
Wer hier nicht steht, sieht das Projekt nicht – auch nicht in der Projektliste.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setOffen(!offen)} className="btn-ghost btn-sm">
|
||||||
|
<UserPlus size={13} /> Person hinzufügen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{offen && (
|
||||||
|
<form
|
||||||
|
action={(fd) => lauf(() => mitgliedHinzufuegen(fd))}
|
||||||
|
className="space-y-3 border-b border-db-line bg-db-dark/40 p-4"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Person</label>
|
||||||
|
<select name="userId" required className="input">
|
||||||
|
<option value="">— wählen —</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.firma ? ` · ${p.firma}` : ""}
|
||||||
|
{p.position ? ` · ${p.position}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Rolle im Projekt</label>
|
||||||
|
<select name="role" value={rolle} onChange={(e) => setRolle(e.target.value)} className="input">
|
||||||
|
{rollen.map((r) => (
|
||||||
|
<option key={r.wert} value={r.wert}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{hinweis && <p className="mt-1 text-[11px] leading-relaxed text-db-muted">{hinweis}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{braucht && (
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">
|
||||||
|
Auf diese Fachbereiche beschränken (leer = alle)
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-1.5 sm:grid-cols-2">
|
||||||
|
{fachbereicheAuswahl.map((f) => (
|
||||||
|
<label key={f} className="flex items-center gap-2 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" name="fachbereiche" value={f} /> {f}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary btn-sm">
|
||||||
|
<Plus size={13} /> hinzufügen
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost btn-sm">
|
||||||
|
abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Person</th>
|
||||||
|
<th className="th">Rolle</th>
|
||||||
|
<th className="th">Fachbereiche</th>
|
||||||
|
<th className="th w-16"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mitglieder.map((m) => (
|
||||||
|
<tr key={m.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<div className="text-slate-100">{m.name}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{m.firma}
|
||||||
|
{m.position && ` · ${m.position}`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-300">{m.roleLabel}</span>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<form action={(fd) => lauf(() => mitgliedEntfernen(fd))}>
|
||||||
|
<input type="hidden" name="memberId" value={m.id} />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
title="aus dem Projekt entfernen"
|
||||||
|
className="p-1 text-db-muted transition hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{fehler && !offen && (
|
||||||
|
<p className="border-t border-db-line px-4 py-2.5 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Check, RefreshCw } from "lucide-react";
|
||||||
|
import { projektdatenSpeichern } from "./actions";
|
||||||
|
|
||||||
|
const iso = (d: Date | null) => (d ? new Date(d).toISOString().slice(0, 10) : "");
|
||||||
|
|
||||||
|
export function ProjektdatenFormular({
|
||||||
|
projectId,
|
||||||
|
werte,
|
||||||
|
fachbereicheAuswahl,
|
||||||
|
aktuelleSlots,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
werte: {
|
||||||
|
name: string;
|
||||||
|
kurzname: string | null;
|
||||||
|
status: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
fachbereiche: string[];
|
||||||
|
startgespraech: Date | null;
|
||||||
|
baubeginn: Date | null;
|
||||||
|
bauende: Date | null;
|
||||||
|
inbetriebnahme: Date | null;
|
||||||
|
bauschlussmeldung: Date | null;
|
||||||
|
projektabschluss: Date | null;
|
||||||
|
};
|
||||||
|
fachbereicheAuswahl: string[];
|
||||||
|
aktuelleSlots: number;
|
||||||
|
}) {
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<string[]>(werte.fachbereiche);
|
||||||
|
const [ergebnis, setErgebnis] = useState<{ neu: number; fristen: number } | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const toggle = (f: string) =>
|
||||||
|
setGewaehlt((g) => (g.includes(f) ? g.filter((x) => x !== f) : [...g, f]));
|
||||||
|
|
||||||
|
const entfernt = werte.fachbereiche.filter((f) => !gewaehlt.includes(f));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="card space-y-4 p-4"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
const r = await projektdatenSpeichern(fd);
|
||||||
|
setErgebnis(r);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Projektdaten und Termine</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
Änderungen an Terminen ziehen die Fristen aller noch offenen Soll-Unterlagen nach.
|
||||||
|
Bereits freigegebene Positionen bleiben unberührt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Bezeichnung</label>
|
||||||
|
<input name="name" defaultValue={werte.name} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Kurzname</label>
|
||||||
|
<input name="kurzname" defaultValue={werte.kurzname ?? ""} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Status</label>
|
||||||
|
<select name="status" defaultValue={werte.status} className="input">
|
||||||
|
{["ANLAGE", "PLANUNG", "VERGABE", "BAU", "INBETRIEBNAHME", "ABNAHME", "GEWAEHRLEISTUNG", "ABGESCHLOSSEN", "RUHEND"].map((s) => (
|
||||||
|
<option key={s} value={s}>{s.charAt(0) + s.slice(1).toLowerCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="label mb-1.5 block">Beschreibung</label>
|
||||||
|
<textarea name="beschreibung" rows={2} defaultValue={werte.beschreibung ?? ""} className="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[
|
||||||
|
["startgespraech", "Startgespräch", werte.startgespraech],
|
||||||
|
["baubeginn", "Baubeginn", werte.baubeginn],
|
||||||
|
["inbetriebnahme", "Inbetriebnahme (IBN)", werte.inbetriebnahme],
|
||||||
|
["bauende", "Bauende", werte.bauende],
|
||||||
|
["bauschlussmeldung", "Bauschlussmeldung", werte.bauschlussmeldung],
|
||||||
|
["projektabschluss", "Projektabschluss (PA)", werte.projektabschluss],
|
||||||
|
].map(([feld, label, wert]) => (
|
||||||
|
<div key={feld as string}>
|
||||||
|
<label className="label mb-1.5 block">{label as string}</label>
|
||||||
|
<input type="date" name={feld as string} defaultValue={iso(wert as Date | null)} className="input" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-db-line pt-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<label className="label">Fachbereiche</label>
|
||||||
|
<span className="text-xs text-db-muted">derzeit {aktuelleSlots} Soll-Unterlagen im Projekt</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 grid gap-2 sm:grid-cols-2">
|
||||||
|
{fachbereicheAuswahl.map((f) => {
|
||||||
|
const an = gewaehlt.includes(f);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={f}
|
||||||
|
className={`flex cursor-pointer items-start gap-2.5 rounded-md border p-2.5 text-sm transition ${
|
||||||
|
an ? "border-db-cyan bg-db-cyan/10 text-slate-100" : "border-db-line text-slate-300 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input type="checkbox" name="fachbereiche" value={f} checked={an} onChange={() => toggle(f)} className="mt-0.5" />
|
||||||
|
<span>{f}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{entfernt.length > 0 && (
|
||||||
|
<p className="mt-2 rounded-md border border-amber-800/50 bg-amber-950/15 px-3 py-2 text-xs leading-relaxed text-amber-200/85">
|
||||||
|
Abgewählt: {entfernt.join(", ")}. Bereits angelegte Soll-Unterlagen bleiben bestehen –
|
||||||
|
das System löscht keine Position, die einmal geführt wurde. Nicht mehr benötigte
|
||||||
|
Positionen setzen Sie im Dokumentationsstand einzeln auf „entfällt“, dann bleibt der
|
||||||
|
Grund dokumentiert.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary">
|
||||||
|
<RefreshCw size={14} className={pending ? "animate-spin" : ""} />
|
||||||
|
{pending ? "wird übernommen …" : "Speichern und Soll-Katalog nachziehen"}
|
||||||
|
</button>
|
||||||
|
{ergebnis && (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-emerald-400">
|
||||||
|
<Check size={14} />
|
||||||
|
{ergebnis.neu > 0 ? `${ergebnis.neu} Positionen ergänzt` : "keine neuen Positionen"}
|
||||||
|
{ergebnis.fristen > 0 && `, ${ergebnis.fristen} Fristen angepasst`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte";
|
||||||
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
import type { ProjectStatus, Role } from "@prisma/client";
|
||||||
|
|
||||||
|
function datumOderNull(v: FormDataEntryValue | null): Date | null {
|
||||||
|
const s = String(v ?? "").trim();
|
||||||
|
return s ? new Date(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sichtbarkeitsschalter und Ablaufregel ändern. */
|
||||||
|
export async function einstellungenSpeichern(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "projekt.bearbeiten");
|
||||||
|
|
||||||
|
const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
|
const neu = {
|
||||||
|
anSichtVertrag: formData.get("anSichtVertrag") === "on",
|
||||||
|
anSichtRechnungen: formData.get("anSichtRechnungen") === "on",
|
||||||
|
anSichtBautagebuch: formData.get("anSichtBautagebuch") === "on",
|
||||||
|
anSichtDokumentation: formData.get("anSichtDokumentation") === "on",
|
||||||
|
anDarfDokumente: formData.get("anDarfDokumente") === "on",
|
||||||
|
freigabeTrotzBefund: formData.get("freigabeTrotzBefund") === "on",
|
||||||
|
};
|
||||||
|
|
||||||
|
await prisma.project.update({ where: { id: projectId }, data: neu });
|
||||||
|
|
||||||
|
const geaendert = Object.entries(neu)
|
||||||
|
.filter(([k, v]) => (vorher as unknown as Record<string, boolean>)[k] !== v)
|
||||||
|
.map(([k, v]) => `${k}: ${v ? "an" : "aus"}`);
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "EINSTELLUNGEN", entitaet: "Project",
|
||||||
|
entitaetId: projectId, projectId, details: { geaendert },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}`, "layout");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Termine und Fachbereiche ändern – zieht Fristen und Soll-Katalog nach. */
|
||||||
|
export async function projektdatenSpeichern(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "projekt.bearbeiten");
|
||||||
|
|
||||||
|
const termine = {
|
||||||
|
startgespraech: datumOderNull(formData.get("startgespraech")),
|
||||||
|
baubeginn: datumOderNull(formData.get("baubeginn")),
|
||||||
|
bauende: datumOderNull(formData.get("bauende")),
|
||||||
|
inbetriebnahme: datumOderNull(formData.get("inbetriebnahme")),
|
||||||
|
bauschlussmeldung: datumOderNull(formData.get("bauschlussmeldung")),
|
||||||
|
projektabschluss: datumOderNull(formData.get("projektabschluss")),
|
||||||
|
};
|
||||||
|
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
|
||||||
|
|
||||||
|
await prisma.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
name: String(formData.get("name") ?? "").trim() || undefined,
|
||||||
|
kurzname: String(formData.get("kurzname") ?? "").trim() || null,
|
||||||
|
status: (formData.get("status") as ProjectStatus) || undefined,
|
||||||
|
beschreibung: String(formData.get("beschreibung") ?? "").trim() || null,
|
||||||
|
...(fachbereiche.length ? { fachbereiche } : {}),
|
||||||
|
...termine,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Meilensteine ohne Ist-Datum an die neuen Termine angleichen
|
||||||
|
const vorhandene = await prisma.milestone.findMany({ where: { projectId } });
|
||||||
|
for (const m of meilensteineAusTerminen(termine)) {
|
||||||
|
const treffer = vorhandene.find((v) => v.typ === m.typ);
|
||||||
|
if (!treffer) await prisma.milestone.create({ data: { ...m, projectId } });
|
||||||
|
else if (!treffer.ist) await prisma.milestone.update({ where: { id: treffer.id }, data: { geplant: m.geplant } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { neu } = await slotsErzeugen(projectId);
|
||||||
|
const fristen = await fristenNeuBerechnen(projectId);
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "UPDATE", entitaet: "Project",
|
||||||
|
entitaetId: projectId, projectId,
|
||||||
|
details: { neueSollUnterlagen: neu, angepassteFristen: fristen },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}`, "layout");
|
||||||
|
return { neu, fristen };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mitgliedHinzufuegen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "projekt.mitglieder");
|
||||||
|
|
||||||
|
const userId = String(formData.get("userId"));
|
||||||
|
const role = String(formData.get("role")) as Role;
|
||||||
|
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
|
||||||
|
if (!userId) throw new Error("Bitte eine Person auswählen.");
|
||||||
|
|
||||||
|
await prisma.projectMember.upsert({
|
||||||
|
where: { projectId_userId_role: { projectId, userId, role } },
|
||||||
|
create: { projectId, userId, role, fachbereiche },
|
||||||
|
update: { fachbereiche },
|
||||||
|
});
|
||||||
|
|
||||||
|
// In den allgemeinen Projektkanal aufnehmen
|
||||||
|
const kanal = await prisma.channel.findFirst({ where: { projectId, kind: "PROJEKT" } });
|
||||||
|
if (kanal) {
|
||||||
|
await prisma.channelMember.upsert({
|
||||||
|
where: { channelId_userId: { channelId: kanal.id, userId } },
|
||||||
|
create: { channelId: kanal.id, userId },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember",
|
||||||
|
entitaetId: userId, projectId, details: { role },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: [userId],
|
||||||
|
typ: "SYSTEM",
|
||||||
|
titel: `Sie wurden dem Projekt ${projekt.nummer} zugeordnet`,
|
||||||
|
text: projekt.name,
|
||||||
|
link: `/projekte/${projectId}`,
|
||||||
|
projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}/einstellungen`);
|
||||||
|
revalidatePath(`/projekte/${projectId}/beteiligte`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function mitgliedEntfernen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const memberId = String(formData.get("memberId"));
|
||||||
|
|
||||||
|
const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } });
|
||||||
|
const roles = await rolesInProject(user.id, mitglied.projectId);
|
||||||
|
assertCan(roles, "projekt.mitglieder");
|
||||||
|
|
||||||
|
// Die letzte Projektleitung darf nicht entfernt werden
|
||||||
|
if (mitglied.role === "PROJEKTLEITER") {
|
||||||
|
const anzahl = await prisma.projectMember.count({
|
||||||
|
where: { projectId: mitglied.projectId, role: "PROJEKTLEITER" },
|
||||||
|
});
|
||||||
|
if (anzahl <= 1) throw new Error("Das Projekt muss mindestens eine Projektleitung behalten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.projectMember.delete({ where: { id: memberId } });
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "MITGLIED_ENTFERNT", entitaet: "ProjectMember",
|
||||||
|
entitaetId: mitglied.userId, projectId: mitglied.projectId, details: { role: mitglied.role },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${mitglied.projectId}/einstellungen`);
|
||||||
|
revalidatePath(`/projekte/${mitglied.projectId}/beteiligte`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, ROLE_LABEL, ROLE_HINT, OPTION_INFO } from "@/lib/rbac";
|
||||||
|
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
||||||
|
import { KeinZugriff } from "@/components/ui";
|
||||||
|
import { EinstellungenFormular } from "./EinstellungenFormular";
|
||||||
|
import { ProjektdatenFormular } from "./ProjektdatenFormular";
|
||||||
|
import { Mitglieder } from "./Mitglieder";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { roles, projekt } = await projektKontext(id);
|
||||||
|
if (!can(roles, "projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
|
||||||
|
|
||||||
|
const [mitglieder, personen, slotStat] = await Promise.all([
|
||||||
|
prisma.projectMember.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: { user: { select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } } } },
|
||||||
|
orderBy: [{ role: "asc" }],
|
||||||
|
}),
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.projectDocumentSlot.count({ where: { projectId: id } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl space-y-5">
|
||||||
|
<ProjektdatenFormular
|
||||||
|
projectId={id}
|
||||||
|
werte={{
|
||||||
|
name: projekt.name,
|
||||||
|
kurzname: projekt.kurzname,
|
||||||
|
status: projekt.status,
|
||||||
|
beschreibung: projekt.beschreibung,
|
||||||
|
fachbereiche: projekt.fachbereiche,
|
||||||
|
startgespraech: projekt.startgespraech,
|
||||||
|
baubeginn: projekt.baubeginn,
|
||||||
|
bauende: projekt.bauende,
|
||||||
|
inbetriebnahme: projekt.inbetriebnahme,
|
||||||
|
bauschlussmeldung: projekt.bauschlussmeldung,
|
||||||
|
projektabschluss: projekt.projektabschluss,
|
||||||
|
}}
|
||||||
|
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
|
||||||
|
aktuelleSlots={slotStat}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EinstellungenFormular
|
||||||
|
projectId={id}
|
||||||
|
optionen={OPTION_INFO.map((o) => ({ ...o }))}
|
||||||
|
werte={{
|
||||||
|
anSichtVertrag: projekt.anSichtVertrag,
|
||||||
|
anSichtRechnungen: projekt.anSichtRechnungen,
|
||||||
|
anSichtBautagebuch: projekt.anSichtBautagebuch,
|
||||||
|
anSichtDokumentation: projekt.anSichtDokumentation,
|
||||||
|
anDarfDokumente: projekt.anDarfDokumente,
|
||||||
|
freigabeTrotzBefund: projekt.freigabeTrotzBefund,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{can(roles, "projekt.mitglieder") && (
|
||||||
|
<Mitglieder
|
||||||
|
projectId={id}
|
||||||
|
mitglieder={mitglieder.map((m) => ({
|
||||||
|
id: m.id,
|
||||||
|
userId: m.user.id,
|
||||||
|
name: m.user.name,
|
||||||
|
position: m.user.position,
|
||||||
|
firma: m.user.company?.shortName ?? m.user.company?.name ?? null,
|
||||||
|
role: m.role,
|
||||||
|
roleLabel: ROLE_LABEL[m.role],
|
||||||
|
fachbereiche: m.fachbereiche,
|
||||||
|
}))}
|
||||||
|
personen={personen.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
position: p.position,
|
||||||
|
firma: p.company?.shortName ?? p.company?.name ?? null,
|
||||||
|
}))}
|
||||||
|
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({
|
||||||
|
wert: r,
|
||||||
|
label: ROLE_LABEL[r],
|
||||||
|
hinweis: ROLE_HINT[r],
|
||||||
|
}))}
|
||||||
|
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useTransition } from "react";
|
||||||
|
import { Send } from "lucide-react";
|
||||||
|
import { nachrichtSenden } from "./actions";
|
||||||
|
|
||||||
|
export function Nachrichtenfeld({ channelId, kanalName }: { channelId: string; kanalName: string }) {
|
||||||
|
const ref = useRef<HTMLFormElement>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
ref={ref}
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await nachrichtSenden(fd);
|
||||||
|
ref.current?.reset();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="border-t border-db-line p-3"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="channelId" value={channelId} />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
name="text"
|
||||||
|
rows={2}
|
||||||
|
required
|
||||||
|
placeholder={`Nachricht an #${kanalName} … (@Nachname erwähnt eine Person)`}
|
||||||
|
className="input resize-none text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
ref.current?.requestSubmit();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary self-end">
|
||||||
|
<Send size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-db-muted">Strg + Eingabe zum Senden</p>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { kanalAnlegen } from "./actions";
|
||||||
|
|
||||||
|
export function NeuerKanal({ projectId, personen }: { projectId: string; personen: { id: string; name: string }[] }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [privat, setPrivat] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen)
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-ghost w-full justify-center">
|
||||||
|
<Plus size={14} /> Kanal anlegen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="card space-y-3 p-3"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await kanalAnlegen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-white">Neuer Kanal</span>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="text-db-muted hover:text-white">
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input name="name" required placeholder="z. B. Oberbau / Sperrpausen" className="input py-1.5 text-sm" />
|
||||||
|
<input name="beschreibung" placeholder="Kurzbeschreibung" className="input py-1.5 text-xs" />
|
||||||
|
<select name="kind" className="input py-1.5 text-xs">
|
||||||
|
<option value="THEMA">Thema</option>
|
||||||
|
<option value="FACHBEREICH">Fachbereich</option>
|
||||||
|
<option value="PROJEKT">Projekt allgemein</option>
|
||||||
|
</select>
|
||||||
|
<label className="flex items-center gap-2 text-xs text-slate-300">
|
||||||
|
<input type="checkbox" name="privat" checked={privat} onChange={(e) => setPrivat(e.target.checked)} />
|
||||||
|
privat – nur ausgewählte Personen
|
||||||
|
</label>
|
||||||
|
{privat && (
|
||||||
|
<select name="mitglieder" multiple size={6} className="input text-xs">
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary btn-sm w-full justify-center">
|
||||||
|
anlegen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { benachrichtige, findeErwaehnungen } from "@/lib/notify";
|
||||||
|
|
||||||
|
export async function nachrichtSenden(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const channelId = String(formData.get("channelId"));
|
||||||
|
const text = String(formData.get("text") ?? "").trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
const kanal = await prisma.channel.findUniqueOrThrow({
|
||||||
|
where: { id: channelId },
|
||||||
|
include: { mitglieder: { select: { userId: true, stumm: true } } },
|
||||||
|
});
|
||||||
|
if (!kanal.projectId) throw new Error("Kanal ohne Projektbezug.");
|
||||||
|
|
||||||
|
const roles = await rolesInProject(user.id, kanal.projectId);
|
||||||
|
assertCan(roles, "chat.schreiben");
|
||||||
|
if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin)
|
||||||
|
throw new Error("Sie sind kein Mitglied dieses Kanals.");
|
||||||
|
|
||||||
|
const erwaehnt = await findeErwaehnungen(text, kanal.projectId);
|
||||||
|
|
||||||
|
await prisma.message.create({
|
||||||
|
data: {
|
||||||
|
channelId,
|
||||||
|
authorId: user.id,
|
||||||
|
text,
|
||||||
|
mentions: erwaehnt,
|
||||||
|
refTyp: (formData.get("refTyp") as string) || null,
|
||||||
|
refId: (formData.get("refId") as string) || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.channelMember.updateMany({
|
||||||
|
where: { channelId, userId: user.id },
|
||||||
|
data: { gelesenBis: new Date() },
|
||||||
|
});
|
||||||
|
|
||||||
|
const link = `/projekte/${kanal.projectId}/kommunikation?kanal=${channelId}`;
|
||||||
|
|
||||||
|
if (erwaehnt.length > 0) {
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: erwaehnt,
|
||||||
|
typ: "ERWAEHNUNG",
|
||||||
|
titel: `${user.name} hat Sie in #${kanal.name} erwähnt`,
|
||||||
|
text,
|
||||||
|
link,
|
||||||
|
projectId: kanal.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const uebrige = kanal.mitglieder
|
||||||
|
.filter((m) => !m.stumm && m.userId !== user.id && !erwaehnt.includes(m.userId))
|
||||||
|
.map((m) => m.userId);
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: uebrige,
|
||||||
|
typ: "NACHRICHT",
|
||||||
|
titel: `Neue Nachricht in #${kanal.name}`,
|
||||||
|
text: `${user.name}: ${text}`,
|
||||||
|
link,
|
||||||
|
projectId: kanal.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${kanal.projectId}/kommunikation`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kanalAnlegen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "chat.kanal.anlegen");
|
||||||
|
|
||||||
|
const privat = formData.get("privat") === "on";
|
||||||
|
const mitgliedIds = formData.getAll("mitglieder").map(String);
|
||||||
|
const alle = privat
|
||||||
|
? [...new Set([user.id, ...mitgliedIds])]
|
||||||
|
: (await prisma.projectMember.findMany({ where: { projectId }, select: { userId: true } })).map((m) => m.userId);
|
||||||
|
|
||||||
|
const kanal = await prisma.channel.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
name: String(formData.get("name")),
|
||||||
|
beschreibung: (formData.get("beschreibung") as string) || null,
|
||||||
|
kind: (formData.get("kind") as never) || "THEMA",
|
||||||
|
privat,
|
||||||
|
mitglieder: { create: [...new Set(alle)].map((userId) => ({ userId })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}/kommunikation`);
|
||||||
|
return kanal.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function alsGelesen(channelId: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
await prisma.channelMember.updateMany({
|
||||||
|
where: { channelId, userId: user.id },
|
||||||
|
data: { gelesenBis: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Hash, Lock, Users } from "lucide-react";
|
||||||
|
import { requireProjectAccess } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import { relativ } from "@/lib/format";
|
||||||
|
import { Nachrichtenfeld } from "./Nachrichtenfeld";
|
||||||
|
import { NeuerKanal } from "./NeuerKanal";
|
||||||
|
import { alsGelesen } from "./actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Kommunikation({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ kanal?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { user, roles } = await requireProjectAccess(id);
|
||||||
|
|
||||||
|
const kanaele = await prisma.channel.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
OR: [{ privat: false }, { mitglieder: { some: { userId: user.id } } }],
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
mitglieder: { where: { userId: user.id }, select: { gelesenBis: true } },
|
||||||
|
_count: { select: { mitglieder: true, nachrichten: true } },
|
||||||
|
nachrichten: { orderBy: { createdAt: "desc" }, take: 1, select: { createdAt: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ kind: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const aktiv = kanaele.find((k) => k.id === sp.kanal) ?? kanaele[0];
|
||||||
|
|
||||||
|
const nachrichten = aktiv
|
||||||
|
? await prisma.message.findMany({
|
||||||
|
where: { channelId: aktiv.id, geloeschtAm: null },
|
||||||
|
include: { author: { select: { id: true, name: true, company: { select: { shortName: true } } } } },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
take: 200,
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (aktiv) await alsGelesen(aktiv.id);
|
||||||
|
|
||||||
|
const mitglieder = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: id },
|
||||||
|
include: { user: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
const personen = [...new Map(mitglieder.map((m) => [m.user.id, m.user])).values()];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||||
|
<aside className="space-y-3">
|
||||||
|
{can(roles, "chat.kanal.anlegen") && <NeuerKanal projectId={id} personen={personen} />}
|
||||||
|
|
||||||
|
<nav className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-db-muted">
|
||||||
|
Kanäle
|
||||||
|
</h2>
|
||||||
|
<ul>
|
||||||
|
{kanaele.map((k) => {
|
||||||
|
const neu =
|
||||||
|
k.nachrichten[0] &&
|
||||||
|
(!k.mitglieder[0]?.gelesenBis || k.nachrichten[0].createdAt > k.mitglieder[0].gelesenBis);
|
||||||
|
return (
|
||||||
|
<li key={k.id}>
|
||||||
|
<Link
|
||||||
|
href={`/projekte/${id}/kommunikation?kanal=${k.id}`}
|
||||||
|
className={`flex items-center gap-2 px-3 py-2 text-sm transition ${
|
||||||
|
aktiv?.id === k.id ? "bg-db-line text-white" : "text-slate-300 hover:bg-db-line/40"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k.privat ? <Lock size={13} className="shrink-0 text-db-muted" /> : <Hash size={13} className="shrink-0 text-db-muted" />}
|
||||||
|
<span className="min-w-0 flex-1 truncate">{k.name}</span>
|
||||||
|
{neu && aktiv?.id !== k.id && <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-db-red" />}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="card-pad text-[11px] leading-relaxed text-db-muted">
|
||||||
|
Nachrichten bleiben im Projekt und sind für neue Beteiligte nachlesbar. Mit <code className="text-slate-300">@Nachname</code>{" "}
|
||||||
|
erwähnte Personen erhalten zusätzlich eine E-Mail – abhängig von deren Einstellung im Profil.
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section className="card flex min-h-[70vh] flex-col overflow-hidden">
|
||||||
|
{aktiv ? (
|
||||||
|
<>
|
||||||
|
<header className="flex items-center gap-2 border-b border-db-line px-4 py-3">
|
||||||
|
{aktiv.privat ? <Lock size={15} className="text-db-muted" /> : <Hash size={15} className="text-db-muted" />}
|
||||||
|
<h2 className="text-sm font-semibold text-white">{aktiv.name}</h2>
|
||||||
|
{aktiv.beschreibung && <span className="text-xs text-db-muted">· {aktiv.beschreibung}</span>}
|
||||||
|
<span className="ml-auto inline-flex items-center gap-1 text-xs text-db-muted">
|
||||||
|
<Users size={13} /> {aktiv._count.mitglieder}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||||
|
{nachrichten.length === 0 ? (
|
||||||
|
<p className="py-10 text-center text-sm text-db-muted">
|
||||||
|
Noch keine Nachrichten. Schreiben Sie die erste.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
nachrichten.map((m, i) => {
|
||||||
|
const gleicherAutor =
|
||||||
|
i > 0 &&
|
||||||
|
nachrichten[i - 1].authorId === m.authorId &&
|
||||||
|
m.createdAt.getTime() - nachrichten[i - 1].createdAt.getTime() < 6e5;
|
||||||
|
return (
|
||||||
|
<div key={m.id} className={gleicherAutor ? "pl-11" : "flex gap-3"}>
|
||||||
|
{!gleicherAutor && (
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-db-line text-xs font-semibold text-slate-200">
|
||||||
|
{m.author.name
|
||||||
|
.split(" ")
|
||||||
|
.map((t) => t[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{!gleicherAutor && (
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-sm font-medium text-slate-100">{m.author.name}</span>
|
||||||
|
{m.author.company?.shortName && (
|
||||||
|
<span className="text-[11px] text-db-muted">{m.author.company.shortName}</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-db-muted">{relativ(m.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={`whitespace-pre-wrap text-sm leading-relaxed ${
|
||||||
|
m.mentions.includes(user.id) ? "rounded bg-amber-950/25 px-2 py-1 text-amber-100" : "text-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.text.split(/(@[\wäöüÄÖÜß.-]+)/g).map((teil, j) =>
|
||||||
|
teil.startsWith("@") ? (
|
||||||
|
<span key={j} className="rounded bg-db-cyan/15 px-1 text-db-cyan">
|
||||||
|
{teil}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
teil
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{can(roles, "chat.schreiben") && <Nachrichtenfeld channelId={aktiv.id} kanalName={aktiv.name} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="p-10 text-center text-sm text-db-muted">Für dieses Projekt ist noch kein Kanal angelegt.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { TabLink } from "@/components/NavLink";
|
||||||
|
import { StatusChip } from "@/components/ui";
|
||||||
|
import { can, canInProject } from "@/lib/rbac";
|
||||||
|
import { datum } from "@/lib/format";
|
||||||
|
|
||||||
|
export default async function ProjektLayout({
|
||||||
|
children,
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { roles, projekt, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
|
const abschnitte = await prisma.projectSection.findMany({ where: { projectId: id }, take: 1 });
|
||||||
|
const a = abschnitte[0];
|
||||||
|
const base = `/projekte/${id}`;
|
||||||
|
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
|
||||||
|
|
||||||
|
const tabs: { href: string; label: string; sichtbar: boolean; exact?: boolean }[] = [
|
||||||
|
{ href: base, label: "Übersicht", sichtbar: true, exact: true },
|
||||||
|
{ href: `${base}/dokumentation`, label: "Dokumentationsstand", sichtbar: darf("ablage.lesen") },
|
||||||
|
{ href: `${base}/dokumente`, label: "Dokumente", sichtbar: darf("dokument.lesen") },
|
||||||
|
{ href: `${base}/bautagebuch`, label: "Bautagebuch", sichtbar: darf("bautagebuch.lesen") },
|
||||||
|
{ href: `${base}/meldungen`, label: "Meldungen & Mängel", sichtbar: true },
|
||||||
|
{ href: `${base}/vertraege`, label: "Verträge & Nachträge", sichtbar: darf("vertrag.lesen") },
|
||||||
|
{ href: `${base}/stunden`, label: "Stunden & Aufmaß", sichtbar: true },
|
||||||
|
{ href: `${base}/rechnungen`, label: "Rechnungsprüfung", sichtbar: darf("rechnung.lesen") },
|
||||||
|
{ href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true },
|
||||||
|
{ href: `${base}/beteiligte`, label: "Beteiligte", sichtbar: true },
|
||||||
|
{ href: `${base}/einstellungen`, label: "Einstellungen", sichtbar: can(roles, "projekt.bearbeiten") },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<header>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<span className="font-mono text-xs text-db-muted">{projekt.nummer}</span>
|
||||||
|
<StatusChip status={projekt.status} />
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-1 text-lg font-semibold text-white">{projekt.name}</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
{a?.streckenNr && `Strecke ${a.streckenNr} ${a.streckenName ?? ""} · km ${a.kmVon}–${a.kmBis}`}
|
||||||
|
{a?.betriebsstelle && ` · ${a.betriebsstelle}`}
|
||||||
|
{nurEigeneFirma && (
|
||||||
|
<span className="ml-2 chip border-db-line bg-db-dark text-slate-400">
|
||||||
|
Sicht: nur eigene Firma
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="flex gap-6 text-xs">
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Baubeginn</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{datum(projekt.baubeginn)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Inbetriebnahme</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{datum(projekt.inbetriebnahme)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Bauende</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{datum(projekt.bauende)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Projektabschluss</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{datum(projekt.projektabschluss)}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-4 flex gap-1 overflow-x-auto border-b border-db-line">
|
||||||
|
{tabs
|
||||||
|
.filter((t) => t.sichtbar)
|
||||||
|
.map((t) => (
|
||||||
|
<TabLink key={t.href} href={t.href} exact={t.exact}>
|
||||||
|
{t.label}
|
||||||
|
</TabLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Plus, X } from "lucide-react";
|
||||||
|
import { meldungAnlegen } from "./actions";
|
||||||
|
|
||||||
|
const TYPEN = [
|
||||||
|
["BEHINDERUNGSANZEIGE", "Behinderungsanzeige (§ 6 Abs. 1 VOB/B)"],
|
||||||
|
["BEDENKENANZEIGE", "Bedenkenanzeige (§ 4 Abs. 3 VOB/B)"],
|
||||||
|
["MANGELANZEIGE", "Mängelanzeige"],
|
||||||
|
["MEHRKOSTEN_AVL", "Mehrkostenanmeldung / Anordnung von Leistungen"],
|
||||||
|
["VERTRAGSABWEICHUNG", "Vertragsabweichung"],
|
||||||
|
["SCHADENSMELDUNG", "Schadensmeldung"],
|
||||||
|
["UNFALLANZEIGE", "Unfallanzeige"],
|
||||||
|
["SOFORTMELDUNG", "Sofortmeldung"],
|
||||||
|
["BETREIBERHINWEIS", "Betreiberhinweis"],
|
||||||
|
["SONSTIGES", "Sonstiges"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function NeueMeldung({
|
||||||
|
projectId,
|
||||||
|
firmen,
|
||||||
|
personen,
|
||||||
|
vertraege,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
firmen: { id: string; name: string }[];
|
||||||
|
personen: { id: string; name: string }[];
|
||||||
|
vertraege: { id: string; nummer: string; bezeichnung: string }[];
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen)
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-primary">
|
||||||
|
<Plus size={15} /> Meldung erfassen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/60 p-4 backdrop-blur-sm">
|
||||||
|
<form
|
||||||
|
className="card mx-auto max-w-2xl space-y-4 p-5"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await meldungAnlegen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Meldung erfassen</h2>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="text-db-muted hover:text-white">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Art der Meldung</label>
|
||||||
|
<select name="typ" required className="input">
|
||||||
|
{TYPEN.map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{v}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Betreff</label>
|
||||||
|
<input name="titel" required className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Sachverhalt</label>
|
||||||
|
<textarea name="beschreibung" rows={4} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Schwere</label>
|
||||||
|
<select name="schwere" defaultValue="MITTEL" className="input">
|
||||||
|
<option value="GERING">gering</option>
|
||||||
|
<option value="MITTEL">mittel</option>
|
||||||
|
<option value="HOCH">hoch</option>
|
||||||
|
<option value="SICHERHEITSRELEVANT">sicherheitsrelevant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Frist</label>
|
||||||
|
<input type="date" name="fristAm" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Zuständig</label>
|
||||||
|
<select name="assigneeId" className="input">
|
||||||
|
<option value="">— offen —</option>
|
||||||
|
{personen.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Gegenüber Firma</label>
|
||||||
|
<select name="gegenFirmaId" className="input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{firmen.map((f) => (
|
||||||
|
<option key={f.id} value={f.id}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Vertrag</label>
|
||||||
|
<select name="contractId" className="input">
|
||||||
|
<option value="">—</option>
|
||||||
|
{vertraege.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.nummer} · {v.bezeichnung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Ort (km)</label>
|
||||||
|
<input name="ortKm" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Fachbereich</label>
|
||||||
|
<input name="fachbereich" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Geschätzte Mehrkosten (EUR)</label>
|
||||||
|
<input type="number" step="0.01" name="geschaetzteKosten" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-4 pb-1 text-xs text-slate-300">
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" name="kostenrelevant" /> kostenrelevant
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5">
|
||||||
|
<input type="checkbox" name="terminrelevant" /> terminrelevant
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary">
|
||||||
|
{pending ? "wird gespeichert …" : "Meldung anlegen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { meldungStatus } from "./actions";
|
||||||
|
import { StatusChip } from "@/components/ui";
|
||||||
|
import type { IssueStatus } from "@prisma/client";
|
||||||
|
|
||||||
|
const STATUS: IssueStatus[] = ["OFFEN", "IN_BEARBEITUNG", "BEHOBEN_GEMELDET", "NACHKONTROLLE", "ERLEDIGT", "ABGELEHNT"];
|
||||||
|
|
||||||
|
export function StatusWechsel({ issueId, status }: { issueId: string; status: string }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [kommentar, setKommentar] = useState("");
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen)
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} title="Status ändern">
|
||||||
|
<StatusChip status={status} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-56 flex-col gap-1.5">
|
||||||
|
<input
|
||||||
|
value={kommentar}
|
||||||
|
onChange={(e) => setKommentar(e.target.value)}
|
||||||
|
placeholder="Vermerk (optional)"
|
||||||
|
className="input py-1 text-xs"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
defaultValue={status}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) =>
|
||||||
|
start(async () => {
|
||||||
|
await meldungStatus(issueId, e.target.value as IssueStatus, kommentar || undefined);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="input py-1 text-xs"
|
||||||
|
>
|
||||||
|
{STATUS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{s.replace(/_/g, " ").toLowerCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button onClick={() => setOffen(false)} className="text-[11px] text-db-muted hover:text-slate-200">
|
||||||
|
abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
import type { IssueStatus, IssueType, Severity } from "@prisma/client";
|
||||||
|
|
||||||
|
export async function meldungAnlegen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "vorgang.anlegen");
|
||||||
|
|
||||||
|
const letzte = await prisma.issue.findFirst({
|
||||||
|
where: { projectId },
|
||||||
|
orderBy: { nr: "desc" },
|
||||||
|
select: { nr: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const assigneeId = (formData.get("assigneeId") as string) || null;
|
||||||
|
const titel = String(formData.get("titel"));
|
||||||
|
|
||||||
|
const vorgang = await prisma.issue.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
nr: (letzte?.nr ?? 0) + 1,
|
||||||
|
typ: formData.get("typ") as IssueType,
|
||||||
|
titel,
|
||||||
|
beschreibung: (formData.get("beschreibung") as string) || null,
|
||||||
|
schwere: (formData.get("schwere") as Severity) || "MITTEL",
|
||||||
|
ortKm: (formData.get("ortKm") as string) || null,
|
||||||
|
fachbereich: (formData.get("fachbereich") as string) || null,
|
||||||
|
raisedById: user.id,
|
||||||
|
assigneeId,
|
||||||
|
gegenFirmaId: (formData.get("gegenFirmaId") as string) || null,
|
||||||
|
contractId: (formData.get("contractId") as string) || null,
|
||||||
|
fristAm: formData.get("fristAm") ? new Date(String(formData.get("fristAm"))) : null,
|
||||||
|
kostenrelevant: formData.get("kostenrelevant") === "on",
|
||||||
|
terminrelevant: formData.get("terminrelevant") === "on",
|
||||||
|
geschaetzteKosten: formData.get("geschaetzteKosten") ? Number(formData.get("geschaetzteKosten")) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "CREATE", entitaet: "Issue",
|
||||||
|
entitaetId: vorgang.id, projectId, details: { nr: vorgang.nr, typ: vorgang.typ },
|
||||||
|
});
|
||||||
|
|
||||||
|
const leitung = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId, role: { in: ["PROJEKTLEITER", "BAUUEBERWACHER", "BAUHERRENVERTRETER"] } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: [...leitung.map((l) => l.userId), ...(assigneeId ? [assigneeId] : [])],
|
||||||
|
typ: assigneeId ? "ZUWEISUNG" : "MANGEL",
|
||||||
|
titel: `Neue Meldung #${vorgang.nr}: ${titel}`,
|
||||||
|
text: (formData.get("beschreibung") as string) || "",
|
||||||
|
link: `/projekte/${projectId}/meldungen`,
|
||||||
|
projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}/meldungen`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } });
|
||||||
|
const roles = await rolesInProject(user.id, vorgang.projectId);
|
||||||
|
assertCan(roles, "vorgang.bearbeiten");
|
||||||
|
|
||||||
|
await prisma.issue.update({
|
||||||
|
where: { id: issueId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
erledigtAm: status === "ERLEDIGT" || status === "ABGELEHNT" ? new Date() : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (kommentar) {
|
||||||
|
await prisma.issueComment.create({ data: { issueId, authorId: user.id, text: kommentar } });
|
||||||
|
}
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "UPDATE", entitaet: "Issue",
|
||||||
|
entitaetId: issueId, projectId: vorgang.projectId, details: { von: vorgang.status, nach: status },
|
||||||
|
});
|
||||||
|
|
||||||
|
const betroffen = [vorgang.raisedById, vorgang.assigneeId].filter(Boolean) as string[];
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: betroffen,
|
||||||
|
typ: "MANGEL",
|
||||||
|
titel: `Meldung #${vorgang.nr}: Status ${status}`,
|
||||||
|
text: kommentar ?? vorgang.titel,
|
||||||
|
link: `/projekte/${vorgang.projectId}/meldungen`,
|
||||||
|
projectId: vorgang.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${vorgang.projectId}/meldungen`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Leer, Kachel } from "@/components/ui";
|
||||||
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
|
import { NeueMeldung } from "./NeueMeldung";
|
||||||
|
import { StatusWechsel } from "./StatusWechsel";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const TYP_LABEL: Record<string, string> = {
|
||||||
|
BEHINDERUNGSANZEIGE: "Behinderungsanzeige",
|
||||||
|
BEDENKENANZEIGE: "Bedenkenanzeige",
|
||||||
|
MANGELANZEIGE: "Mängelanzeige",
|
||||||
|
MEHRKOSTEN_AVL: "Mehrkosten / AvL",
|
||||||
|
SCHADENSMELDUNG: "Schadensmeldung",
|
||||||
|
UNFALLANZEIGE: "Unfallanzeige",
|
||||||
|
SOFORTMELDUNG: "Sofortmeldung",
|
||||||
|
VERTRAGSABWEICHUNG: "Vertragsabweichung",
|
||||||
|
BETREIBERHINWEIS: "Betreiberhinweis",
|
||||||
|
SONSTIGES: "Sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Meldungen({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ typ?: string; status?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { user, roles, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
|
const [vorgaenge, firmen, mitglieder, vertraege] = await Promise.all([
|
||||||
|
prisma.issue.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
...(sp.typ ? { typ: sp.typ as never } : {}),
|
||||||
|
...(sp.status === "offen" ? { status: { notIn: ["ERLEDIGT", "ABGELEHNT"] } } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
raisedBy: { select: { name: true } },
|
||||||
|
assignee: { select: { name: true } },
|
||||||
|
gegenFirma: { select: { name: true } },
|
||||||
|
contract: { select: { nummer: true } },
|
||||||
|
kommentare: { include: { author: { select: { name: true } } }, orderBy: { createdAt: "asc" } },
|
||||||
|
},
|
||||||
|
orderBy: [{ status: "asc" }, { nr: "desc" }],
|
||||||
|
}),
|
||||||
|
// Auswahllisten im Erfassungsformular: Auftragnehmer bekommen darin
|
||||||
|
// ausschließlich die eigene Firma und die eigenen Verträge angeboten.
|
||||||
|
prisma.company.findMany({
|
||||||
|
where: nurEigeneFirma
|
||||||
|
? { id: user.companyId ?? "__keine__" }
|
||||||
|
: { type: "AUFTRAGNEHMER" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
}),
|
||||||
|
prisma.projectMember.findMany({ where: { projectId: id }, include: { user: { select: { id: true, name: true } } } }),
|
||||||
|
prisma.contract.findMany({
|
||||||
|
where: { projectId: id, ...(nurEigeneFirma ? { companyId: user.companyId ?? "__keine__" } : {}) },
|
||||||
|
select: { id: true, nummer: true, bezeichnung: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const offen = vorgaenge.filter((v) => v.status !== "ERLEDIGT" && v.status !== "ABGELEHNT");
|
||||||
|
const ueberfaellig = offen.filter((v) => v.fristAm && v.fristAm < new Date());
|
||||||
|
const sicherheit = offen.filter((v) => v.schwere === "SICHERHEITSRELEVANT");
|
||||||
|
const kosten = offen.reduce((s, v) => s + Number(v.geschaetzteKosten ?? 0), 0);
|
||||||
|
|
||||||
|
const personen = [...new Map(mitglieder.map((m) => [m.user.id, m.user])).values()];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel titel="Offene Vorgänge" wert={offen.length} hinweis={`${vorgaenge.length} insgesamt`} ton={offen.length ? "warnung" : "gut"} />
|
||||||
|
<Kachel titel="Fristen überschritten" wert={ueberfaellig.length} ton={ueberfaellig.length ? "kritisch" : "gut"} />
|
||||||
|
<Kachel titel="Sicherheitsrelevant" wert={sicherheit.length} ton={sicherheit.length ? "kritisch" : "gut"} />
|
||||||
|
<Kachel titel="Angemeldete Mehrkosten" wert={eur(kosten)} hinweis="aus offenen Vorgängen" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link href={`/projekte/${id}/meldungen`} className={`btn btn-sm border ${!sp.status && !sp.typ ? "border-db-cyan text-db-cyan" : "border-db-line text-db-muted"}`}>
|
||||||
|
alle
|
||||||
|
</Link>
|
||||||
|
<Link href={`/projekte/${id}/meldungen?status=offen`} className={`btn btn-sm border ${sp.status === "offen" ? "border-db-cyan text-db-cyan" : "border-db-line text-db-muted"}`}>
|
||||||
|
nur offene
|
||||||
|
</Link>
|
||||||
|
{Object.entries(TYP_LABEL).slice(0, 5).map(([k, v]) => (
|
||||||
|
<Link key={k} href={`/projekte/${id}/meldungen?typ=${k}`} className={`btn btn-sm border ${sp.typ === k ? "border-db-cyan text-db-cyan" : "border-db-line text-db-muted"}`}>
|
||||||
|
{v}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
<div className="ml-auto">
|
||||||
|
{can(roles, "vorgang.anlegen") && (
|
||||||
|
<NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorgaenge.length === 0 ? (
|
||||||
|
<Leer text="Keine Meldungen erfasst." />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{vorgaenge.map((v) => {
|
||||||
|
const tage = tageBis(v.fristAm);
|
||||||
|
const erledigt = v.status === "ERLEDIGT" || v.status === "ABGELEHNT";
|
||||||
|
return (
|
||||||
|
<article key={v.id} className={`card p-4 ${erledigt ? "opacity-60" : ""}`}>
|
||||||
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
<span className="mt-0.5 font-mono text-xs text-db-muted">#{v.nr}</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-medium text-slate-100">{v.titel}</h3>
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-400">{TYP_LABEL[v.typ]}</span>
|
||||||
|
<StatusChip status={v.schwere} />
|
||||||
|
</div>
|
||||||
|
{v.beschreibung && (
|
||||||
|
<p className="mt-1.5 whitespace-pre-wrap text-sm leading-relaxed text-slate-400">{v.beschreibung}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[11px] text-db-muted">
|
||||||
|
<span>gemeldet von {v.raisedBy.name} am {datum(v.gemeldetAm)}</span>
|
||||||
|
{v.assignee && <span>zuständig: {v.assignee.name}</span>}
|
||||||
|
{v.gegenFirma && <span>gegenüber {v.gegenFirma.name}</span>}
|
||||||
|
{v.contract && <span>Vertrag {v.contract.nummer}</span>}
|
||||||
|
{v.ortKm && <span>km {v.ortKm}</span>}
|
||||||
|
{v.fristAm && (
|
||||||
|
<span className={!erledigt && tage !== null && tage < 0 ? "font-medium text-red-400" : !erledigt && tage !== null && tage < 3 ? "text-amber-400" : ""}>
|
||||||
|
Frist {datum(v.fristAm)}
|
||||||
|
{!erledigt && tage !== null && (tage < 0 ? ` (${-tage} Tg. überschritten)` : ` (in ${tage} Tg.)`)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{v.geschaetzteKosten && <span>Mehrkosten {eur(v.geschaetzteKosten)}</span>}
|
||||||
|
{v.erledigtAm && <span>erledigt am {datum(v.erledigtAm)}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{v.kommentare.length > 0 && (
|
||||||
|
<ul className="mt-3 space-y-1.5 border-l-2 border-db-line pl-3">
|
||||||
|
{v.kommentare.map((k) => (
|
||||||
|
<li key={k.id} className="text-xs">
|
||||||
|
<span className="text-slate-300">{k.author.name}</span>{" "}
|
||||||
|
<span className="text-db-muted">{datum(k.createdAt)}:</span>{" "}
|
||||||
|
<span className="text-slate-400">{k.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0">
|
||||||
|
{can(roles, "vorgang.bearbeiten") ? (
|
||||||
|
<StatusWechsel issueId={v.id} status={v.status} />
|
||||||
|
) : (
|
||||||
|
<StatusChip status={v.status} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { canInProject } from "@/lib/rbac";
|
||||||
|
import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui";
|
||||||
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
|
import { CheckCircle2, Circle, CircleDot } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function ProjektUebersicht({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
searchParams: Promise<{ angelegt?: string }>;
|
||||||
|
}) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { angelegt } = await searchParams;
|
||||||
|
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
|
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
|
||||||
|
const zeigeVertraege = darf("vertrag.lesen");
|
||||||
|
const zeigeDokumentation = darf("ablage.lesen");
|
||||||
|
const zeigeTagebuch = darf("bautagebuch.lesen");
|
||||||
|
|
||||||
|
// Auftragnehmer bekommen ausschließlich Zahlen der eigenen Firma zu sehen.
|
||||||
|
const eigeneFirma = nurEigeneFirma ? { companyId: user.companyId ?? "__keine__" } : {};
|
||||||
|
|
||||||
|
const [projekt, slots, vorgaenge, vertraege, rechnungen, tagebuch, warranties] = await Promise.all([
|
||||||
|
prisma.project.findUniqueOrThrow({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
bauwerke: true,
|
||||||
|
abschnitte: true,
|
||||||
|
milestones: { orderBy: { geplant: "asc" } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
zeigeDokumentation
|
||||||
|
? prisma.projectDocumentSlot.groupBy({ by: ["status"], where: { projectId: id }, _count: true })
|
||||||
|
: [],
|
||||||
|
prisma.issue.groupBy({ by: ["status"], where: { projectId: id }, _count: true }),
|
||||||
|
zeigeVertraege
|
||||||
|
? prisma.contract.findMany({
|
||||||
|
where: { projectId: id, ...eigeneFirma },
|
||||||
|
include: { company: { select: { name: true } }, nachtraege: true },
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
zeigeVertraege
|
||||||
|
? prisma.invoice.findMany({
|
||||||
|
where: { projectId: id, ...eigeneFirma },
|
||||||
|
select: { status: true, nettoBetrag: true, anerkanntNetto: true },
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
zeigeTagebuch
|
||||||
|
? prisma.siteDiaryEntry.findFirst({
|
||||||
|
where: { projectId: id },
|
||||||
|
orderBy: { datum: "desc" },
|
||||||
|
select: { datum: true, lfdNr: true },
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
nurEigeneFirma
|
||||||
|
? []
|
||||||
|
: prisma.warranty.findMany({ where: { projectId: id }, orderBy: { fristEndeAm: "asc" }, take: 4 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const gesamt = slots.reduce((s, x) => s + x._count, 0);
|
||||||
|
const fertig = slots.filter((s) => s.status === "FREIGEGEBEN" || s.status === "ENTFAELLT").reduce((s, x) => s + x._count, 0);
|
||||||
|
const grad = gesamt ? Math.round((fertig / gesamt) * 100) : 0;
|
||||||
|
|
||||||
|
const offeneVorgaenge = vorgaenge
|
||||||
|
.filter((v) => v.status !== "ERLEDIGT" && v.status !== "ABGELEHNT")
|
||||||
|
.reduce((s, x) => s + x._count, 0);
|
||||||
|
|
||||||
|
const auftragssumme = vertraege.reduce(
|
||||||
|
(s, v) =>
|
||||||
|
s +
|
||||||
|
Number(v.auftragssumme) +
|
||||||
|
v.nachtraege.filter((n) => n.status === "BEAUFTRAGT").reduce((t, n) => t + Number(n.anerkannteSumme ?? n.angebotssumme ?? 0), 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const abgerechnet = rechnungen
|
||||||
|
.filter((r) => r.status !== "ZURUECKGEWIESEN")
|
||||||
|
.reduce((s, r) => s + Number(r.anerkanntNetto ?? r.nettoBetrag), 0);
|
||||||
|
|
||||||
|
const offeneNachtraege = vertraege.flatMap((v) => v.nachtraege).filter((n) => n.status !== "BEAUFTRAGT" && n.status !== "ABGELEHNT");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{angelegt && (
|
||||||
|
<div className="rounded-md border border-emerald-800/60 bg-emerald-950/20 px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-emerald-300">Projekt angelegt.</p>
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-emerald-200/70">
|
||||||
|
{angelegt} Soll-Unterlagen aus der EIU-Ablagestruktur wurden für die gewählten Fachbereiche
|
||||||
|
übernommen und mit Fristen versehen.{" "}
|
||||||
|
<Link href={`/projekte/${id}/dokumentation`} className="underline">
|
||||||
|
Dokumentationsstand ansehen
|
||||||
|
</Link>{" "}
|
||||||
|
oder unter{" "}
|
||||||
|
<Link href={`/projekte/${id}/einstellungen`} className="underline">
|
||||||
|
Einstellungen
|
||||||
|
</Link>{" "}
|
||||||
|
weitere Beteiligte aufnehmen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{zeigeDokumentation && (
|
||||||
|
<Kachel
|
||||||
|
titel="Dokumentationsstand"
|
||||||
|
wert={`${grad} %`}
|
||||||
|
hinweis={`${fertig} von ${gesamt} Soll-Unterlagen`}
|
||||||
|
ton={grad > 66 ? "gut" : grad > 33 ? "warnung" : "kritisch"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Kachel titel="Offene Vorgänge" wert={offeneVorgaenge} hinweis="Meldungen, Mängel, Behinderungen" ton={offeneVorgaenge > 0 ? "warnung" : "gut"} />
|
||||||
|
{zeigeVertraege && (
|
||||||
|
<>
|
||||||
|
<Kachel
|
||||||
|
titel={nurEigeneFirma ? "Eigenes Auftragsvolumen" : "Auftragsvolumen"}
|
||||||
|
wert={eur(auftragssumme)}
|
||||||
|
hinweis={`${vertraege.length} ${vertraege.length === 1 ? "Vertrag" : "Verträge"} inkl. beauftragter Nachträge`}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
titel="Abgerechnet"
|
||||||
|
wert={`${auftragssumme ? Math.round((abgerechnet / auftragssumme) * 100) : 0} %`}
|
||||||
|
hinweis={eur(abgerechnet)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<section className="card lg:col-span-2">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Terminplan</h2>
|
||||||
|
<ol className="divide-y divide-db-line/70">
|
||||||
|
{projekt.milestones.map((m) => {
|
||||||
|
const erreicht = !!m.ist;
|
||||||
|
const tage = tageBis(m.geplant);
|
||||||
|
const naechster = !erreicht && tage !== null && tage >= 0;
|
||||||
|
return (
|
||||||
|
<li key={m.id} className="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
{erreicht ? (
|
||||||
|
<CheckCircle2 size={16} className="shrink-0 text-emerald-500" />
|
||||||
|
) : naechster ? (
|
||||||
|
<CircleDot size={16} className="shrink-0 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Circle size={16} className="shrink-0 text-slate-600" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm text-slate-200">{m.titel}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
geplant {datum(m.geplant)}
|
||||||
|
{m.ist && ` · erreicht ${datum(m.ist)}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-xs tabular-nums text-db-muted">
|
||||||
|
{erreicht ? "erledigt" : tage === null ? "—" : tage < 0 ? `${-tage} Tg. über` : `in ${tage} Tg.`}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Projektdaten</h2>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<Feld label="Beschreibung">
|
||||||
|
<p className="text-xs leading-relaxed text-slate-300">{projekt.beschreibung}</p>
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Fachbereiche">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{projekt.fachbereiche.map((f) => (
|
||||||
|
<span key={f} className="chip border-db-line bg-db-dark text-slate-300">
|
||||||
|
{f}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Streckenabschnitte">
|
||||||
|
<ul className="space-y-0.5 text-xs text-slate-300">
|
||||||
|
{projekt.abschnitte.map((a) => (
|
||||||
|
<li key={a.id}>
|
||||||
|
Strecke {a.streckenNr} · km {a.kmVon} – {a.kmBis}
|
||||||
|
{a.betriebsstelle && ` · ${a.betriebsstelle}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Bauwerke und Anlagen">
|
||||||
|
<ul className="space-y-0.5 text-xs text-slate-300">
|
||||||
|
{projekt.bauwerke.map((b) => (
|
||||||
|
<li key={b.id}>
|
||||||
|
{b.bezeichnung}
|
||||||
|
<span className="text-db-muted">
|
||||||
|
{b.bauwerksNr && ` · ${b.bauwerksNr}`}
|
||||||
|
{b.kmLage && ` · km ${b.kmLage}`}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Feld>
|
||||||
|
{tagebuch && (
|
||||||
|
<Feld label="Letzter Bautagebucheintrag">
|
||||||
|
<Link href={`/projekte/${id}/bautagebuch`} className="link text-xs">
|
||||||
|
Nr. {tagebuch.lfdNr} vom {datum(tagebuch.datum)}
|
||||||
|
</Link>
|
||||||
|
</Feld>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
{zeigeVertraege && (
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
{nurEigeneFirma ? "Eigene Verträge" : "Verträge"}
|
||||||
|
</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{vertraege.map((v) => {
|
||||||
|
const nt = v.nachtraege.filter((n) => n.status === "BEAUFTRAGT");
|
||||||
|
return (
|
||||||
|
<tr key={v.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<Link href={`/projekte/${id}/vertraege`} className="text-slate-200 hover:text-db-cyan">
|
||||||
|
{v.bezeichnung}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{v.nummer} · {v.company.name}
|
||||||
|
{nt.length > 0 && ` · ${nt.length} beauftragte Nachträge`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums">{eur(v.auftragssumme)}</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<StatusChip status={v.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{offeneNachtraege.length > 0 && (
|
||||||
|
<p className="border-t border-db-line px-4 py-2.5 text-xs text-amber-400">
|
||||||
|
{offeneNachtraege.length} Nachtrag/Nachträge in Prüfung ·{" "}
|
||||||
|
{eur(offeneNachtraege.reduce((s, n) => s + Number(n.angebotssumme ?? 0), 0))} angemeldet
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(zeigeDokumentation || warranties.length > 0) && (
|
||||||
|
<section className="card">
|
||||||
|
{zeigeDokumentation && (
|
||||||
|
<>
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
Dokumentationsstand nach Akte
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
<AkteBalken projectId={id} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{warranties.length > 0 && (
|
||||||
|
<div className="border-t border-db-line px-4 py-3">
|
||||||
|
<div className="label mb-1.5">Mängelanspruchsfristen</div>
|
||||||
|
<ul className="space-y-1 text-xs">
|
||||||
|
{warranties.map((w) => (
|
||||||
|
<li key={w.id} className="flex justify-between gap-3">
|
||||||
|
<span className="truncate text-slate-300">{w.gegenstand}</span>
|
||||||
|
<span className="shrink-0 tabular-nums text-db-muted">bis {datum(w.fristEndeAm)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function AkteBalken({ projectId }: { projectId: string }) {
|
||||||
|
const rows = await prisma.projectDocumentSlot.findMany({
|
||||||
|
where: { projectId },
|
||||||
|
select: { status: true, requirement: { select: { nodeCode: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const AKTEN = [
|
||||||
|
{ k: "P", label: "Planungsakte" },
|
||||||
|
{ k: "B", label: "Baudokumentation / Bauakten" },
|
||||||
|
{ k: "K", label: "Kaufmännische Akte" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{AKTEN.map(({ k, label }) => {
|
||||||
|
const teil = rows.filter((r) => r.requirement.nodeCode.startsWith(k));
|
||||||
|
const fertig = teil.filter((r) => r.status === "FREIGEGEBEN" || r.status === "ENTFAELLT").length;
|
||||||
|
const proz = teil.length ? Math.round((fertig / teil.length) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div key={k}>
|
||||||
|
<div className="mb-1 flex justify-between text-xs">
|
||||||
|
<span className="text-slate-300">{label}</span>
|
||||||
|
<span className="tabular-nums text-db-muted">
|
||||||
|
{fertig}/{teil.length} · {proz} %
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Fortschritt wert={proz} ton={proz > 66 ? "gut" : proz > 33 ? "warnung" : "kritisch"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { befundErledigen } from "../actions";
|
||||||
|
|
||||||
|
export function BefundErledigen({ findingId }: { findingId: string }) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen)
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="mt-1 text-[11px] text-db-muted hover:text-db-cyan">
|
||||||
|
als geklärt vermerken
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await befundErledigen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="mt-1.5 flex w-64 flex-col gap-1.5"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="findingId" value={findingId} />
|
||||||
|
<textarea name="grund" rows={2} required placeholder="Wie wurde der Befund geklärt?" className="input text-xs" />
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button type="submit" disabled={pending} className="btn-ghost btn-sm flex-1 justify-center">
|
||||||
|
speichern
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost btn-sm">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { CheckCircle2, Play, ShieldCheck, Wallet, XCircle } from "lucide-react";
|
||||||
|
import { pruefungStarten, sachlichRichtig, rechnerischRichtig, zahlungFreigeben, zurueckweisen } from "../actions";
|
||||||
|
import { eur } from "@/lib/format";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
invoiceId: string;
|
||||||
|
status: string;
|
||||||
|
kritischeBefunde: number;
|
||||||
|
vorschlagKuerzung: number;
|
||||||
|
netto: number;
|
||||||
|
darfPruefen: boolean;
|
||||||
|
darfSachlich: boolean;
|
||||||
|
darfRechnerisch: boolean;
|
||||||
|
darfFreigeben: boolean;
|
||||||
|
historie: { sachlich: string | null; rechnerisch: string | null; freigabe: string | null };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PruefLeiste(p: Props) {
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [formular, setFormular] = useState<null | "sachlich" | "rechnerisch" | "zurueck">(null);
|
||||||
|
|
||||||
|
const lauf = (fn: () => Promise<unknown>) =>
|
||||||
|
start(async () => {
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
setFormular(null);
|
||||||
|
} catch (e) {
|
||||||
|
setFehler(e instanceof Error ? e.message : "Die Aktion konnte nicht ausgeführt werden.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const Schritt = ({
|
||||||
|
nr,
|
||||||
|
titel,
|
||||||
|
erledigt,
|
||||||
|
info,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
nr: number;
|
||||||
|
titel: string;
|
||||||
|
erledigt: string | null;
|
||||||
|
info: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div className={`flex-1 rounded-md border p-3 ${erledigt ? "border-emerald-800/60 bg-emerald-950/15" : "border-db-line bg-db-dark/40"}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`flex h-5 w-5 items-center justify-center rounded-full text-[11px] font-bold ${erledigt ? "bg-emerald-600 text-white" : "bg-db-line text-db-muted"}`}>
|
||||||
|
{erledigt ? "✓" : nr}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-slate-100">{titel}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[11px] leading-relaxed text-db-muted">{erledigt ?? info}</p>
|
||||||
|
{!erledigt && children && <div className="mt-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card space-y-3 p-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Prüfung und Freigabe</h2>
|
||||||
|
{p.darfPruefen && (
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => lauf(() => pruefungStarten(p.invoiceId))}
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<Play size={13} /> {pending ? "prüft …" : "Automatische Prüfung erneut ausführen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row">
|
||||||
|
<Schritt
|
||||||
|
nr={1}
|
||||||
|
titel="Sachlich richtig"
|
||||||
|
erledigt={p.historie.sachlich}
|
||||||
|
info="Bauüberwachung bestätigt, dass die Leistung erbracht und durch Aufmaß bzw. Stundenzettel belegt ist."
|
||||||
|
>
|
||||||
|
{p.darfSachlich && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button onClick={() => setFormular(formular === "sachlich" ? null : "sachlich")} className="btn-ghost btn-sm w-full justify-center">
|
||||||
|
<ShieldCheck size={13} /> bescheinigen
|
||||||
|
</button>
|
||||||
|
{formular === "sachlich" && (
|
||||||
|
<form
|
||||||
|
action={(fd) => lauf(() => sachlichRichtig(fd))}
|
||||||
|
className="space-y-2 rounded border border-db-line bg-db-slate p-2.5"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="invoiceId" value={p.invoiceId} />
|
||||||
|
<label className="label block">Kürzung netto (EUR)</label>
|
||||||
|
<input
|
||||||
|
name="kuerzung"
|
||||||
|
defaultValue={String(p.vorschlagKuerzung).replace(".", ",")}
|
||||||
|
className="input py-1 text-right text-xs tabular-nums"
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-db-muted">
|
||||||
|
Vorschlag aus dem Abgleich: {eur(p.vorschlagKuerzung)} · anerkannt {eur(p.netto - p.vorschlagKuerzung)}
|
||||||
|
</p>
|
||||||
|
<textarea name="begruendung" rows={3} placeholder="Begründung der Kürzung" className="input text-xs" />
|
||||||
|
{p.kritischeBefunde > 0 && (
|
||||||
|
<label className="flex items-start gap-1.5 text-[11px] text-amber-300">
|
||||||
|
<input type="checkbox" name="trotzBefunden" className="mt-0.5" />
|
||||||
|
<span>
|
||||||
|
Trotz {p.kritischeBefunde} kritischer Befunde bescheinigen (bewusste Entscheidung, wird
|
||||||
|
protokolliert)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary btn-sm w-full justify-center">
|
||||||
|
sachlich richtig
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setFormular(formular === "zurueck" ? null : "zurueck")} className="btn btn-sm w-full justify-center border border-red-900/60 text-red-300 hover:bg-red-950/40">
|
||||||
|
<XCircle size={13} /> zurückweisen
|
||||||
|
</button>
|
||||||
|
{formular === "zurueck" && (
|
||||||
|
<form action={(fd) => lauf(() => zurueckweisen(fd))} className="space-y-2 rounded border border-db-line bg-db-slate p-2.5">
|
||||||
|
<input type="hidden" name="invoiceId" value={p.invoiceId} />
|
||||||
|
<textarea name="begruendung" rows={3} required placeholder="Grund der Zurückweisung" className="input text-xs" />
|
||||||
|
<button type="submit" disabled={pending} className="btn btn-sm w-full justify-center border border-red-800 bg-red-900/30 text-red-300">
|
||||||
|
Rechnung zurückweisen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Schritt>
|
||||||
|
|
||||||
|
<Schritt
|
||||||
|
nr={2}
|
||||||
|
titel="Rechnerisch richtig"
|
||||||
|
erledigt={p.historie.rechnerisch}
|
||||||
|
info="Kaufmännische Steuerung prüft Rechenwerk, Einbehalt, Skonto und Zahlungsziel. Muss eine andere Person sein."
|
||||||
|
>
|
||||||
|
{p.darfRechnerisch && p.status === "SACHLICH_RICHTIG" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button onClick={() => setFormular(formular === "rechnerisch" ? null : "rechnerisch")} className="btn-ghost btn-sm w-full justify-center">
|
||||||
|
<CheckCircle2 size={13} /> bescheinigen
|
||||||
|
</button>
|
||||||
|
{formular === "rechnerisch" && (
|
||||||
|
<form action={(fd) => lauf(() => rechnerischRichtig(fd))} className="space-y-2 rounded border border-db-line bg-db-slate p-2.5">
|
||||||
|
<input type="hidden" name="invoiceId" value={p.invoiceId} />
|
||||||
|
<label className="label block">Sicherheitseinbehalt (EUR)</label>
|
||||||
|
<input name="einbehalt" className="input py-1 text-right text-xs tabular-nums" />
|
||||||
|
<label className="label block">Skonto (EUR)</label>
|
||||||
|
<input name="skonto" className="input py-1 text-right text-xs tabular-nums" />
|
||||||
|
<textarea name="bemerkung" rows={2} placeholder="Bemerkung" className="input text-xs" />
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary btn-sm w-full justify-center">
|
||||||
|
rechnerisch richtig
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Schritt>
|
||||||
|
|
||||||
|
<Schritt
|
||||||
|
nr={3}
|
||||||
|
titel="Zahlungsfreigabe"
|
||||||
|
erledigt={p.historie.freigabe}
|
||||||
|
info="Projektleitung oder Bauherrenvertretung gibt die Zahlung frei."
|
||||||
|
>
|
||||||
|
{p.darfFreigeben && p.status === "RECHNERISCH_RICHTIG" && (
|
||||||
|
<form action={(fd) => lauf(() => zahlungFreigeben(fd))}>
|
||||||
|
<input type="hidden" name="invoiceId" value={p.invoiceId} />
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary btn-sm w-full justify-center">
|
||||||
|
<Wallet size={13} /> Zahlung freigeben
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Schritt>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui";
|
||||||
|
import { datum, datumZeit, eur, zahl } from "@/lib/format";
|
||||||
|
import { FINDING_LABEL } from "@/lib/rechnungspruefung";
|
||||||
|
import { PruefLeiste } from "./PruefLeiste";
|
||||||
|
import { BefundErledigen } from "./BefundErledigen";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function RechnungDetail({ params }: { params: Promise<{ id: string; rid: string }> }) {
|
||||||
|
const { id, rid } = await params;
|
||||||
|
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
if (!canInProject(roles, "rechnung.lesen", optionen))
|
||||||
|
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
||||||
|
|
||||||
|
const r = await prisma.invoice.findUnique({
|
||||||
|
where: { id: rid },
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
contract: { include: { nachtraege: true } },
|
||||||
|
zeilen: {
|
||||||
|
orderBy: { position: "asc" },
|
||||||
|
include: {
|
||||||
|
contractItem: { select: { oz: true, kurztext: true, einheitspreis: true, einheit: true } },
|
||||||
|
timeSheet: { select: { nummer: true, status: true, id: true } },
|
||||||
|
changeOrder: { select: { nummer: true, titel: true, status: true } },
|
||||||
|
befunde: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
befunde: { orderBy: [{ schwere: "asc" }, { createdAt: "asc" }] },
|
||||||
|
sachlichBy: { select: { name: true } },
|
||||||
|
rechnerischBy: { select: { name: true } },
|
||||||
|
freigabeBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!r || r.projectId !== id) notFound();
|
||||||
|
// Auftragnehmer dürfen nur die eigene Rechnung öffnen
|
||||||
|
if (nurEigeneFirma && r.companyId !== user.companyId) notFound();
|
||||||
|
|
||||||
|
const offeneBefunde = r.befunde.filter((b) => !b.erledigt);
|
||||||
|
const kritisch = offeneBefunde.filter((b) => b.schwere === "KRITISCH");
|
||||||
|
const summeDifferenz = offeneBefunde.reduce((s, b) => s + Math.max(0, Number(b.differenz ?? 0)), 0);
|
||||||
|
const befundeProZeile = new Map<string, typeof r.befunde>();
|
||||||
|
for (const b of r.befunde) {
|
||||||
|
if (!b.lineId) continue;
|
||||||
|
if (!befundeProZeile.has(b.lineId)) befundeProZeile.set(b.lineId, []);
|
||||||
|
befundeProZeile.get(b.lineId)!.push(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Link href={`/projekte/${id}/rechnungen`} className="inline-flex items-center gap-1.5 text-xs text-db-muted hover:text-slate-200">
|
||||||
|
<ArrowLeft size={13} /> zurück zur Übersicht
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<section className="card-pad lg:col-span-2">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h1 className="text-lg font-semibold text-white">{r.rechnungsNr}</h1>
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-db-muted">
|
||||||
|
{r.company.name}
|
||||||
|
{r.company.kreditorNr && ` · Kreditor ${r.company.kreditorNr}`}
|
||||||
|
{r.contract && ` · Vertrag ${r.contract.nummer}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-semibold tabular-nums text-white">{eur(r.nettoBetrag)}</div>
|
||||||
|
<div className="text-xs text-db-muted">netto · brutto {eur(r.bruttoBetrag)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-4 grid grid-cols-2 gap-3 border-t border-db-line pt-4 sm:grid-cols-4">
|
||||||
|
<Feld label="Rechnungsart">{r.typ.replace(/_/g, " ").toLowerCase()}</Feld>
|
||||||
|
<Feld label="Rechnungsdatum">{datum(r.rechnungsdatum)}</Feld>
|
||||||
|
<Feld label="Eingang">{datum(r.eingangsdatum)}</Feld>
|
||||||
|
<Feld label="Zahlbar bis">{datum(r.zahlbarBis)}</Feld>
|
||||||
|
<Feld label="Leistungszeitraum">
|
||||||
|
{datum(r.leistungVon)} – {datum(r.leistungBis)}
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Umsatzsteuer">
|
||||||
|
{zahl(r.ustSatz, 0)} % · {eur(r.ustBetrag)}
|
||||||
|
</Feld>
|
||||||
|
<Feld label="Sicherheitseinbehalt">{eur(r.einbehalt)}</Feld>
|
||||||
|
<Feld label="Skonto">{eur(r.skonto)}</Feld>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<Kachel
|
||||||
|
titel="Prüfergebnis"
|
||||||
|
wert={r.anerkanntNetto !== null ? eur(r.anerkanntNetto) : "—"}
|
||||||
|
hinweis={
|
||||||
|
r.letzterPrueflauf
|
||||||
|
? `zuletzt geprüft ${datumZeit(r.letzterPrueflauf)}`
|
||||||
|
: "noch kein Prüflauf durchgeführt"
|
||||||
|
}
|
||||||
|
ton={Number(r.kuerzungNetto) > 0 ? "warnung" : "gut"}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
titel="Vorgeschlagene Kürzung"
|
||||||
|
wert={eur(r.kuerzungNetto)}
|
||||||
|
hinweis={summeDifferenz > 0 ? `${eur(summeDifferenz)} aus offenen Befunden` : undefined}
|
||||||
|
ton={Number(r.kuerzungNetto) > 0 ? "kritisch" : "gut"}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Prüfschritte */}
|
||||||
|
<PruefLeiste
|
||||||
|
invoiceId={r.id}
|
||||||
|
status={r.status}
|
||||||
|
kritischeBefunde={kritisch.length}
|
||||||
|
vorschlagKuerzung={Number(r.kuerzungNetto)}
|
||||||
|
netto={Number(r.nettoBetrag)}
|
||||||
|
// Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter
|
||||||
|
darfPruefen={can(roles, "rechnung.lesen")}
|
||||||
|
darfSachlich={can(roles, "rechnung.sachlich")}
|
||||||
|
darfRechnerisch={can(roles, "rechnung.rechnerisch") && r.sachlichById !== user.id}
|
||||||
|
darfFreigeben={can(roles, "rechnung.freigeben")}
|
||||||
|
historie={{
|
||||||
|
sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null,
|
||||||
|
rechnerisch: r.rechnerischBy ? `${r.rechnerischBy.name}, ${datumZeit(r.rechnerischAm)}` : null,
|
||||||
|
freigabe: r.freigabeBy ? `${r.freigabeBy.name}, ${datumZeit(r.freigabeAm)}` : null,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Befunde */}
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<header className="flex items-center gap-2 border-b border-db-line px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Prüfbericht</h2>
|
||||||
|
<span className="text-xs text-db-muted">
|
||||||
|
{offeneBefunde.length} offen ({kritisch.length} kritisch) · {r.befunde.length - offeneBefunde.length} geklärt
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{r.befunde.length === 0 ? (
|
||||||
|
<p className="flex items-center gap-2 px-4 py-6 text-sm text-db-muted">
|
||||||
|
<Info size={15} /> Es wurde noch kein Prüflauf durchgeführt oder es ergaben sich keine Beanstandungen.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-db-line/70">
|
||||||
|
{r.befunde.map((b) => (
|
||||||
|
<li key={b.id} className={`px-4 py-3 ${b.erledigt ? "opacity-50" : ""}`}>
|
||||||
|
<div className="flex flex-wrap items-start gap-3">
|
||||||
|
{b.schwere === "KRITISCH" ? (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-red-400" />
|
||||||
|
) : b.schwere === "WARNUNG" ? (
|
||||||
|
<AlertTriangle size={16} className="mt-0.5 shrink-0 text-amber-400" />
|
||||||
|
) : (
|
||||||
|
<Info size={16} className="mt-0.5 shrink-0 text-db-muted" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-300">{FINDING_LABEL[b.art]}</span>
|
||||||
|
<StatusChip status={b.schwere} />
|
||||||
|
{b.erledigt && <span className="chip border-emerald-800 bg-emerald-950/40 text-emerald-400">geklärt</span>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 text-sm text-slate-200">{b.meldung}</p>
|
||||||
|
{(b.erwartet || b.gefunden) && (
|
||||||
|
<p className="mt-1 text-xs text-db-muted">
|
||||||
|
{b.erwartet && (
|
||||||
|
<>
|
||||||
|
Beleg / Vertrag: <span className="text-emerald-400">{b.erwartet}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{b.erwartet && b.gefunden && " · "}
|
||||||
|
{b.gefunden && (
|
||||||
|
<>
|
||||||
|
Rechnung: <span className="text-red-400">{b.gefunden}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{b.erledigtGrund && <p className="mt-1 text-xs text-db-muted">Klärung: {b.erledigtGrund}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
{b.differenz !== null && Number(b.differenz) !== 0 && (
|
||||||
|
<div className={`text-sm font-medium tabular-nums ${Number(b.differenz) > 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{Number(b.differenz) > 0 ? "+" : ""}
|
||||||
|
{eur(b.differenz)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!b.erledigt && can(roles, "rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Positionen */}
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Rechnungspositionen</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th w-10">Pos.</th>
|
||||||
|
<th className="th">Bezeichnung</th>
|
||||||
|
<th className="th w-28">Beleg</th>
|
||||||
|
<th className="th text-right">Menge</th>
|
||||||
|
<th className="th text-right">EP</th>
|
||||||
|
<th className="th text-right">Betrag</th>
|
||||||
|
<th className="th text-right">anerkannt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{r.zeilen.map((z) => {
|
||||||
|
const bef = befundeProZeile.get(z.id)?.filter((b) => !b.erledigt) ?? [];
|
||||||
|
const gekuerzt = z.betragAnerkannt !== null && Number(z.betragAnerkannt) < Number(z.betrag) - 0.01;
|
||||||
|
return (
|
||||||
|
<tr key={z.id} className={`row ${bef.length ? "bg-red-950/15" : ""}`}>
|
||||||
|
<td className="td tabular-nums text-db-muted">{z.position}</td>
|
||||||
|
<td className="td">
|
||||||
|
<div className="text-slate-100">{z.bezeichnung}</div>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{z.oz && <span className="font-mono">{z.oz} · </span>}
|
||||||
|
{z.art.replace(/_/g, " ").toLowerCase()}
|
||||||
|
{z.contractItem && ` · Vertrags-EP ${eur(z.contractItem.einheitspreis)}`}
|
||||||
|
</div>
|
||||||
|
{bef.length > 0 && (
|
||||||
|
<ul className="mt-1 space-y-0.5">
|
||||||
|
{bef.map((b) => (
|
||||||
|
<li key={b.id} className="text-[11px] text-red-300">
|
||||||
|
▸ {FINDING_LABEL[b.art]}
|
||||||
|
{b.erwartet && ` – Beleg: ${b.erwartet}`}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs">
|
||||||
|
{z.timeSheet && (
|
||||||
|
<Link href={`/projekte/${id}/stunden`} className="link">
|
||||||
|
{z.timeSheet.nummer}
|
||||||
|
<div className="text-[10px] text-db-muted">{z.timeSheet.status.toLowerCase()}</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{z.changeOrder && (
|
||||||
|
<span className="text-slate-300">
|
||||||
|
{z.changeOrder.nummer}
|
||||||
|
<div className="text-[10px] text-db-muted">{z.changeOrder.status.toLowerCase()}</div>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{!z.timeSheet && !z.changeOrder && <span className="text-db-muted">Aufmaß</span>}
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-300">
|
||||||
|
{zahl(z.menge, 2)} {z.einheit}
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-300">{eur(z.einheitspreis)}</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-100">{eur(z.betrag)}</td>
|
||||||
|
<td className={`td whitespace-nowrap text-right tabular-nums ${gekuerzt ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{z.betragAnerkannt !== null ? eur(z.betragAnerkannt) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-t-2 border-db-line font-semibold">
|
||||||
|
<td className="td" colSpan={5}>
|
||||||
|
Summe netto
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-white">{eur(r.nettoBetrag)}</td>
|
||||||
|
<td className={`td text-right tabular-nums ${Number(r.kuerzungNetto) > 0 ? "text-amber-400" : "text-emerald-400"}`}>
|
||||||
|
{r.anerkanntNetto !== null ? eur(r.anerkanntNetto) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{r.kuerzungGrund && (
|
||||||
|
<div className="card-pad">
|
||||||
|
<div className="label">Begründung der Kürzung / Zurückweisung</div>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">{r.kuerzungGrund}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { pruefeRechnung } from "@/lib/rechnungspruefung";
|
||||||
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
|
export async function pruefungStarten(invoiceId: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
|
const roles = await rolesInProject(user.id, rechnung.projectId);
|
||||||
|
assertCan(roles, "rechnung.lesen");
|
||||||
|
|
||||||
|
const bericht = await pruefeRechnung(invoiceId);
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "PRUEFLAUF", entitaet: "Invoice",
|
||||||
|
entitaetId: invoiceId, projectId: rechnung.projectId, details: bericht,
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`);
|
||||||
|
return bericht;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "sachlich richtig" – Bescheinigung durch die Bauüberwachung. */
|
||||||
|
export async function sachlichRichtig(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
|
const rechnung = await prisma.invoice.findUniqueOrThrow({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, rechnung.projectId);
|
||||||
|
assertCan(roles, "rechnung.sachlich");
|
||||||
|
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } });
|
||||||
|
const ignorieren = formData.get("trotzBefunden") === "on";
|
||||||
|
|
||||||
|
if (rechnung.befunde.length > 0) {
|
||||||
|
if (!projekt.freigabeTrotzBefund) {
|
||||||
|
throw new Error(
|
||||||
|
`Es liegen ${rechnung.befunde.length} kritische Befunde vor. In diesem Projekt ist eine Bescheinigung ` +
|
||||||
|
"trotz kritischer Befunde nicht zugelassen – die Befunde sind zuvor zu klären.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!ignorieren) {
|
||||||
|
throw new Error(
|
||||||
|
`Es liegen ${rechnung.befunde.length} kritische Befunde vor. Bitte zunächst klären oder die Bestätigung ausdrücklich mit Begründung erteilen.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!String(formData.get("begruendung") ?? "").trim()) {
|
||||||
|
throw new Error("Für eine Bescheinigung trotz kritischer Befunde ist eine Begründung erforderlich.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const kuerzung = formData.get("kuerzung") ? Number(String(formData.get("kuerzung")).replace(",", ".")) : null;
|
||||||
|
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: {
|
||||||
|
status: "SACHLICH_RICHTIG",
|
||||||
|
sachlichById: user.id,
|
||||||
|
sachlichAm: new Date(),
|
||||||
|
kuerzungNetto: kuerzung ?? rechnung.kuerzungNetto,
|
||||||
|
anerkanntNetto: kuerzung !== null ? Number(rechnung.nettoBetrag) - kuerzung : rechnung.anerkanntNetto,
|
||||||
|
kuerzungGrund: (formData.get("begruendung") as string) || rechnung.kuerzungGrund,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "SACHLICH_RICHTIG", entitaet: "Invoice",
|
||||||
|
entitaetId: invoiceId, projectId: rechnung.projectId,
|
||||||
|
details: { kuerzung, trotzBefunden: ignorieren },
|
||||||
|
});
|
||||||
|
|
||||||
|
const kaufleute = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: rechnung.projectId, role: "KAUFMANN" },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: kaufleute.map((k) => k.userId),
|
||||||
|
typ: "RECHNUNG_BEFUND",
|
||||||
|
titel: `Rechnung ${rechnung.rechnungsNr} sachlich bestätigt`,
|
||||||
|
text: "Die Bauüberwachung hat die sachliche Richtigkeit bescheinigt. Die rechnerische Prüfung kann erfolgen.",
|
||||||
|
link: `/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`,
|
||||||
|
projectId: rechnung.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "rechnerisch richtig" – kaufmännische Prüfung. */
|
||||||
|
export async function rechnerischRichtig(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
|
const roles = await rolesInProject(user.id, rechnung.projectId);
|
||||||
|
assertCan(roles, "rechnung.rechnerisch");
|
||||||
|
|
||||||
|
if (rechnung.status !== "SACHLICH_RICHTIG")
|
||||||
|
throw new Error("Die sachliche Richtigkeit muss zuerst durch die Bauüberwachung bescheinigt werden.");
|
||||||
|
if (rechnung.sachlichById === user.id)
|
||||||
|
throw new Error("Sachliche und rechnerische Prüfung dürfen nicht von derselben Person erfolgen.");
|
||||||
|
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: {
|
||||||
|
status: "RECHNERISCH_RICHTIG",
|
||||||
|
rechnerischById: user.id,
|
||||||
|
rechnerischAm: new Date(),
|
||||||
|
einbehalt: formData.get("einbehalt") ? Number(String(formData.get("einbehalt")).replace(",", ".")) : rechnung.einbehalt,
|
||||||
|
skonto: formData.get("skonto") ? Number(String(formData.get("skonto")).replace(",", ".")) : rechnung.skonto,
|
||||||
|
bemerkung: (formData.get("bemerkung") as string) || rechnung.bemerkung,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "RECHNERISCH_RICHTIG", entitaet: "Invoice",
|
||||||
|
entitaetId: invoiceId, projectId: rechnung.projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const leitung = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId: rechnung.projectId, role: { in: ["PROJEKTLEITER", "BAUHERRENVERTRETER"] } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: leitung.map((l) => l.userId),
|
||||||
|
typ: "RECHNUNG_BEFUND",
|
||||||
|
titel: `Rechnung ${rechnung.rechnungsNr} zur Zahlungsfreigabe`,
|
||||||
|
text: "Sachlich und rechnerisch geprüft. Die Zahlungsfreigabe steht aus.",
|
||||||
|
link: `/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`,
|
||||||
|
projectId: rechnung.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zahlungFreigeben(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
|
const roles = await rolesInProject(user.id, rechnung.projectId);
|
||||||
|
assertCan(roles, "rechnung.freigeben");
|
||||||
|
|
||||||
|
if (rechnung.status !== "RECHNERISCH_RICHTIG")
|
||||||
|
throw new Error("Vor der Zahlungsfreigabe müssen sachliche und rechnerische Prüfung abgeschlossen sein.");
|
||||||
|
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { status: "FREIGEGEBEN", freigabeById: user.id, freigabeAm: new Date() },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "ZAHLUNGSFREIGABE", entitaet: "Invoice",
|
||||||
|
entitaetId: invoiceId, projectId: rechnung.projectId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zurueckweisen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
|
const roles = await rolesInProject(user.id, rechnung.projectId);
|
||||||
|
assertCan(roles, "rechnung.sachlich");
|
||||||
|
|
||||||
|
await prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: { status: "ZURUECKGEWIESEN", kuerzungGrund: (formData.get("begruendung") as string) || null },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "ZURUECKWEISUNG", entitaet: "Invoice",
|
||||||
|
entitaetId: invoiceId, projectId: rechnung.projectId,
|
||||||
|
details: { grund: formData.get("begruendung") },
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${rechnung.projectId}/rechnungen/${invoiceId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function befundErledigen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const findingId = String(formData.get("findingId"));
|
||||||
|
const befund = await prisma.invoiceFinding.findUniqueOrThrow({
|
||||||
|
where: { id: findingId },
|
||||||
|
include: { invoice: { select: { projectId: true, id: true } } },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, befund.invoice.projectId);
|
||||||
|
assertCan(roles, "rechnung.sachlich");
|
||||||
|
|
||||||
|
await prisma.invoiceFinding.update({
|
||||||
|
where: { id: findingId },
|
||||||
|
data: { erledigt: true, erledigtGrund: String(formData.get("grund") || "ohne Angabe") },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "BEFUND_ERLEDIGT", entitaet: "InvoiceFinding",
|
||||||
|
entitaetId: findingId, projectId: befund.invoice.projectId,
|
||||||
|
details: { grund: formData.get("grund") },
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${befund.invoice.projectId}/rechnungen/${befund.invoice.id}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
||||||
|
import { datum, eur } from "@/lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
if (!canInProject(roles, "rechnung.lesen", optionen))
|
||||||
|
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
||||||
|
|
||||||
|
const rechnungen = await prisma.invoice.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
// Auftragnehmer sehen ausschließlich die eigenen Rechnungen
|
||||||
|
...(nurEigeneFirma ? { companyId: user.companyId ?? "__keine__" } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: { select: { name: true, kreditorNr: true } },
|
||||||
|
contract: { select: { nummer: true } },
|
||||||
|
befunde: { select: { schwere: true, erledigt: true } },
|
||||||
|
},
|
||||||
|
orderBy: { eingangsdatum: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const inPruefung = rechnungen.filter((r) => ["EINGEGANGEN", "IN_PRUEFUNG", "RUECKFRAGE", "SACHLICH_RICHTIG"].includes(r.status));
|
||||||
|
const kritisch = rechnungen.reduce((s, r) => s + r.befunde.filter((b) => b.schwere === "KRITISCH" && !b.erledigt).length, 0);
|
||||||
|
const gestellt = rechnungen.filter((r) => r.status !== "ZURUECKGEWIESEN").reduce((s, r) => s + Number(r.nettoBetrag), 0);
|
||||||
|
const gekuerzt = rechnungen.reduce((s, r) => s + Number(r.kuerzungNetto), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel titel="In Prüfung" wert={inPruefung.length} hinweis={`${rechnungen.length} Rechnungen insgesamt`} ton={inPruefung.length ? "warnung" : "gut"} />
|
||||||
|
<Kachel titel="Kritische Befunde" wert={kritisch} hinweis="offen, aus dem automatischen Abgleich" ton={kritisch ? "kritisch" : "gut"} />
|
||||||
|
<Kachel titel="Netto gestellt" wert={eur(gestellt)} />
|
||||||
|
<Kachel
|
||||||
|
titel="Kürzung nach Prüfung"
|
||||||
|
wert={eur(gekuerzt)}
|
||||||
|
hinweis={gestellt > 0 ? `${((gekuerzt / gestellt) * 100).toFixed(1)} % der gestellten Summe` : undefined}
|
||||||
|
ton={gekuerzt > 0 ? "warnung" : "gut"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-pad text-xs leading-relaxed text-db-muted">
|
||||||
|
Jede eingehende Rechnung wird automatisch gegen Vertrag, anerkannte Aufmaße, bestätigte Stundenlohnzettel und
|
||||||
|
bereits abgerechnete Mengen geprüft. Der Prüfbericht ist ein Vorschlag – die Bescheinigung „sachlich richtig“
|
||||||
|
(Bauüberwachung) und „rechnerisch richtig“ (kaufmännische Steuerung) bleiben getrennte, personengebundene
|
||||||
|
Entscheidungen.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rechnungen.length === 0 ? (
|
||||||
|
<Leer text="Für dieses Projekt liegen keine Rechnungen vor." />
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Rechnung</th>
|
||||||
|
<th className="th">Auftragnehmer / Vertrag</th>
|
||||||
|
<th className="th">Zeitraum</th>
|
||||||
|
<th className="th text-right">Netto gestellt</th>
|
||||||
|
<th className="th text-right">anerkannt</th>
|
||||||
|
<th className="th text-right">Befunde</th>
|
||||||
|
<th className="th text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rechnungen.map((r) => {
|
||||||
|
const offen = r.befunde.filter((b) => !b.erledigt);
|
||||||
|
const krit = offen.filter((b) => b.schwere === "KRITISCH").length;
|
||||||
|
return (
|
||||||
|
<tr key={r.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<Link href={`/projekte/${id}/rechnungen/${r.id}`} className="link font-medium">
|
||||||
|
{r.rechnungsNr}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{r.typ.replace(/_/g, " ").toLowerCase()} · Eingang {datum(r.eingangsdatum)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-slate-300">
|
||||||
|
{r.company.name}
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{r.contract?.nummer ?? "ohne Vertragsbezug"}
|
||||||
|
{r.company.kreditorNr && ` · Kreditor ${r.company.kreditorNr}`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-xs text-db-muted">
|
||||||
|
{datum(r.leistungVon)} – {datum(r.leistungBis)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-200">{eur(r.nettoBetrag)}</td>
|
||||||
|
<td className="td text-right tabular-nums">
|
||||||
|
{r.anerkanntNetto !== null ? (
|
||||||
|
<span className={Number(r.kuerzungNetto) > 0 ? "text-amber-400" : "text-emerald-400"}>
|
||||||
|
{eur(r.anerkanntNetto)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-db-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
{offen.length > 0 ? (
|
||||||
|
<span className={`chip ${krit ? "border-red-800 bg-red-900/25 text-red-300" : "border-amber-700 bg-amber-900/25 text-amber-300"}`}>
|
||||||
|
<AlertTriangle size={11} /> {offen.length}
|
||||||
|
</span>
|
||||||
|
) : r.letzterPrueflauf ? (
|
||||||
|
<span className="chip border-emerald-700 bg-emerald-900/25 text-emerald-300">ohne Befund</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-db-muted">nicht geprüft</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import { aufmassPruefen } from "./actions";
|
||||||
|
import { zahl } from "@/lib/format";
|
||||||
|
|
||||||
|
type Zeile = { id: string; oz: string; text: string; formel: string; einheit: string; menge: number };
|
||||||
|
|
||||||
|
export function AufmassPruefung({ measurementId, zeilen }: { measurementId: string; zeilen: Zeile[] }) {
|
||||||
|
const [werte, setWerte] = useState<Record<string, string>>(
|
||||||
|
Object.fromEntries(zeilen.map((z) => [z.id, String(z.menge).replace(".", ",")])),
|
||||||
|
);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
const num = (s: string) => {
|
||||||
|
const n = Number((s ?? "").replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={(fd) => start(() => aufmassPruefen(fd))} className="space-y-3">
|
||||||
|
<input type="hidden" name="measurementId" value={measurementId} />
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-db-line/60">
|
||||||
|
<th className="th">OZ</th>
|
||||||
|
<th className="th">Beschreibung</th>
|
||||||
|
<th className="th">Berechnung</th>
|
||||||
|
<th className="th text-right">Menge</th>
|
||||||
|
<th className="th w-32 text-right">anerkannt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((z) => {
|
||||||
|
const a = num(werte[z.id]);
|
||||||
|
return (
|
||||||
|
<tr key={z.id} className="row">
|
||||||
|
<td className="td font-mono text-xs text-db-muted">{z.oz}</td>
|
||||||
|
<td className="td text-slate-200">{z.text}</td>
|
||||||
|
<td className="td font-mono text-xs text-db-muted">{z.formel}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">
|
||||||
|
{zahl(z.menge, 2)} {z.einheit}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<input
|
||||||
|
name={`menge_${z.id}`}
|
||||||
|
value={werte[z.id] ?? ""}
|
||||||
|
onChange={(e) => setWerte({ ...werte, [z.id]: e.target.value })}
|
||||||
|
className={`input py-1 text-right text-xs tabular-nums ${a < z.menge ? "border-amber-600 text-amber-300" : ""}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<textarea name="bemerkung" rows={2} placeholder="Prüfvermerk zum Aufmaß" className="input text-sm" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="entscheidung"
|
||||||
|
value="ANERKANNT"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn border border-emerald-700 bg-emerald-900/30 text-emerald-300 hover:bg-emerald-900/50"
|
||||||
|
>
|
||||||
|
<Check size={14} /> Mengen anerkennen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="entscheidung"
|
||||||
|
value="ABGELEHNT"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn border border-red-800 bg-red-900/30 text-red-300 hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
<X size={14} /> zurückweisen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Plus, Trash2, X } from "lucide-react";
|
||||||
|
import { zettelEinreichen } from "./actions";
|
||||||
|
|
||||||
|
const ARTEN = ["FACHARBEITER", "VORARBEITER", "POLIER", "HELFER", "MASCHINIST", "SICHERUNGSPOSTEN", "GERAET", "SONSTIGES"];
|
||||||
|
|
||||||
|
export function NeuerZettel({
|
||||||
|
projectId,
|
||||||
|
vertraege,
|
||||||
|
}: {
|
||||||
|
projectId: string;
|
||||||
|
vertraege: { id: string; nummer: string; bezeichnung: string }[];
|
||||||
|
}) {
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
const [zeilen, setZeilen] = useState([0]);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
if (!offen)
|
||||||
|
return (
|
||||||
|
<button onClick={() => setOffen(true)} className="btn-primary">
|
||||||
|
<Plus size={15} /> Stundenlohnzettel einreichen
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-40 overflow-y-auto bg-black/60 p-4 backdrop-blur-sm">
|
||||||
|
<form
|
||||||
|
className="card mx-auto max-w-3xl space-y-4 p-5"
|
||||||
|
action={(fd) =>
|
||||||
|
start(async () => {
|
||||||
|
await zettelEinreichen(fd);
|
||||||
|
setOffen(false);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="projectId" value={projectId} />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Stundenlohnzettel einreichen</h2>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="text-db-muted hover:text-white">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Vertrag</label>
|
||||||
|
<select name="contractId" required className="input">
|
||||||
|
{vertraege.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.nummer} · {v.bezeichnung}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Datum</label>
|
||||||
|
<input type="date" name="datum" required defaultValue={new Date().toISOString().slice(0, 10)} className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Zettel-Nr. (optional)</label>
|
||||||
|
<input name="nummer" placeholder="wird sonst vergeben" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Ort (km)</label>
|
||||||
|
<input name="ortKm" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Bezug OZ / Nachtrag</label>
|
||||||
|
<input name="bezugPosition" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="label mb-1.5 block">Ausgeführte Leistung</label>
|
||||||
|
<textarea name="leistung" rows={2} required className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<span className="label">Stunden</span>
|
||||||
|
<button type="button" onClick={() => setZeilen([...zeilen, Date.now()])} className="btn-ghost btn-sm">
|
||||||
|
<Plus size={12} /> Zeile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="th">Name / Gerät</th>
|
||||||
|
<th className="th w-44">Art</th>
|
||||||
|
<th className="th w-24">Stunden</th>
|
||||||
|
<th className="th w-28">Satz (EUR)</th>
|
||||||
|
<th className="th w-8"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((k) => (
|
||||||
|
<tr key={k}>
|
||||||
|
<td className="pr-1.5 pt-1.5">
|
||||||
|
<input name="name" className="input py-1 text-xs" />
|
||||||
|
</td>
|
||||||
|
<td className="px-1.5 pt-1.5">
|
||||||
|
<select name="art" className="input py-1 text-xs">
|
||||||
|
{ARTEN.map((a) => (
|
||||||
|
<option key={a} value={a}>
|
||||||
|
{a.charAt(0) + a.slice(1).toLowerCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-1.5 pt-1.5">
|
||||||
|
<input name="stunden" className="input py-1 text-right text-xs" />
|
||||||
|
</td>
|
||||||
|
<td className="px-1.5 pt-1.5">
|
||||||
|
<input name="satz" className="input py-1 text-right text-xs" />
|
||||||
|
</td>
|
||||||
|
<td className="pt-1.5">
|
||||||
|
{zeilen.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZeilen(zeilen.filter((x) => x !== k))}
|
||||||
|
className="p-1 text-db-muted hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending} className="btn-primary">
|
||||||
|
{pending ? "wird eingereicht …" : "Einreichen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setOffen(false)} className="btn-ghost">
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useTransition } from "react";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
import { zettelPruefen } from "./actions";
|
||||||
|
import { zahl, eur } from "@/lib/format";
|
||||||
|
|
||||||
|
type Zeile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
art: string;
|
||||||
|
gemeldet: number;
|
||||||
|
satz: number;
|
||||||
|
zuschlag: number;
|
||||||
|
zuschlagGrund: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ZettelPruefung({ timeSheetId, zeilen }: { timeSheetId: string; zeilen: Zeile[] }) {
|
||||||
|
const [werte, setWerte] = useState<Record<string, string>>(
|
||||||
|
Object.fromEntries(zeilen.map((z) => [z.id, String(z.gemeldet).replace(".", ",")])),
|
||||||
|
);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const num = (s: string) => {
|
||||||
|
const n = Number(s.replace(",", "."));
|
||||||
|
return Number.isFinite(n) ? n : 0;
|
||||||
|
};
|
||||||
|
const summeAnerkannt = zeilen.reduce((s, z) => s + num(werte[z.id] ?? "0"), 0);
|
||||||
|
const summeGemeldet = zeilen.reduce((s, z) => s + z.gemeldet, 0);
|
||||||
|
const wert = zeilen.reduce((s, z) => s + num(werte[z.id] ?? "0") * z.satz * (1 + z.zuschlag / 100), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={(fd) => start(() => zettelPruefen(fd))} className="mt-3 space-y-3">
|
||||||
|
<input type="hidden" name="timeSheetId" value={timeSheetId} />
|
||||||
|
|
||||||
|
<div className="rounded-md border border-amber-800/50 bg-amber-950/15 p-3">
|
||||||
|
<p className="text-xs text-amber-200/80">
|
||||||
|
Prüfung durch die Bauüberwachung. Anerkannte Stunden je Zeile eintragen – sie sind später die verbindliche
|
||||||
|
Obergrenze für die Rechnungsprüfung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-db-line/60">
|
||||||
|
<th className="th">Name / Gerät</th>
|
||||||
|
<th className="th">Art</th>
|
||||||
|
<th className="th text-right">gemeldet</th>
|
||||||
|
<th className="th w-28 text-right">anerkannt</th>
|
||||||
|
<th className="th text-right">Satz</th>
|
||||||
|
<th className="th text-right">Zuschlag</th>
|
||||||
|
<th className="th text-right">Wert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{zeilen.map((z) => {
|
||||||
|
const a = num(werte[z.id] ?? "0");
|
||||||
|
return (
|
||||||
|
<tr key={z.id} className="row">
|
||||||
|
<td className="td text-slate-200">{z.name}</td>
|
||||||
|
<td className="td text-xs text-db-muted">{z.art}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{zahl(z.gemeldet, 2)}</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<input
|
||||||
|
name={`std_${z.id}`}
|
||||||
|
value={werte[z.id] ?? ""}
|
||||||
|
onChange={(e) => setWerte({ ...werte, [z.id]: e.target.value })}
|
||||||
|
className={`input py-1 text-right text-xs tabular-nums ${a < z.gemeldet ? "border-amber-600 text-amber-300" : ""}`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{eur(z.satz)}</td>
|
||||||
|
<td className="td text-right text-xs text-db-muted">
|
||||||
|
{z.zuschlag > 0 ? <span title={z.zuschlagGrund ?? ""}>+{zahl(z.zuschlag, 0)} %</span> : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-200">{eur(a * z.satz * (1 + z.zuschlag / 100))}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-t border-db-line font-medium">
|
||||||
|
<td className="td" colSpan={2}>
|
||||||
|
Summe
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{zahl(summeGemeldet, 2)}</td>
|
||||||
|
<td className={`td text-right tabular-nums ${summeAnerkannt < summeGemeldet ? "text-amber-400" : "text-emerald-400"}`}>
|
||||||
|
{zahl(summeAnerkannt, 2)}
|
||||||
|
</td>
|
||||||
|
<td className="td" colSpan={2}></td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-100">{eur(wert)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
name="kommentar"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Prüfvermerk – z. B. „An- und Abfahrtszeiten sind mit den Einheitspreisen abgegolten und wurden abgesetzt.“"
|
||||||
|
className="input text-sm"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="entscheidung"
|
||||||
|
value="ANERKANNT"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn border border-emerald-700 bg-emerald-900/30 text-emerald-300 hover:bg-emerald-900/50"
|
||||||
|
>
|
||||||
|
<Check size={14} /> {summeAnerkannt < summeGemeldet ? "teilweise anerkennen" : "anerkennen"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="entscheidung"
|
||||||
|
value="ABGELEHNT"
|
||||||
|
disabled={pending}
|
||||||
|
className="btn border border-red-800 bg-red-900/30 text-red-300 hover:bg-red-900/50"
|
||||||
|
>
|
||||||
|
<X size={14} /> ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { assertCan } from "@/lib/rbac";
|
||||||
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüfung eines Stundenlohnzettels durch die Bauüberwachung.
|
||||||
|
* Anerkannt wird zeilenweise – genau das ist später die Messlatte
|
||||||
|
* für die Rechnungsprüfung.
|
||||||
|
*/
|
||||||
|
export async function zettelPruefen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const timeSheetId = String(formData.get("timeSheetId"));
|
||||||
|
|
||||||
|
const zettel = await prisma.timeSheet.findUniqueOrThrow({
|
||||||
|
where: { id: timeSheetId },
|
||||||
|
include: { zeilen: true },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, zettel.projectId);
|
||||||
|
assertCan(roles, "stunden.pruefen");
|
||||||
|
|
||||||
|
let abweichung = false;
|
||||||
|
for (const z of zettel.zeilen) {
|
||||||
|
const roh = formData.get(`std_${z.id}`);
|
||||||
|
if (roh === null) continue;
|
||||||
|
const anerkannt = Number(String(roh).replace(",", "."));
|
||||||
|
if (!Number.isFinite(anerkannt) || anerkannt < 0) continue;
|
||||||
|
if (Math.abs(anerkannt - Number(z.stundenGemeldet)) > 0.001) abweichung = true;
|
||||||
|
await prisma.timeSheetLine.update({ where: { id: z.id }, data: { stundenAnerkannt: anerkannt } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entscheidung = String(formData.get("entscheidung"));
|
||||||
|
const status =
|
||||||
|
entscheidung === "ABGELEHNT" ? "ABGELEHNT" : abweichung ? "TEILWEISE_ANERKANNT" : "ANERKANNT";
|
||||||
|
|
||||||
|
await prisma.timeSheet.update({
|
||||||
|
where: { id: timeSheetId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
checkedById: user.id,
|
||||||
|
geprueftAm: new Date(),
|
||||||
|
pruefkommentar: (formData.get("kommentar") as string) || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "PRUEFUNG", entitaet: "TimeSheet",
|
||||||
|
entitaetId: timeSheetId, projectId: zettel.projectId, details: { status },
|
||||||
|
});
|
||||||
|
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: [zettel.filedById],
|
||||||
|
typ: "ZUWEISUNG",
|
||||||
|
titel: `Stundenlohnzettel ${zettel.nummer}: ${status.replace(/_/g, " ").toLowerCase()}`,
|
||||||
|
text: (formData.get("kommentar") as string) || "Die Bauüberwachung hat den Zettel geprüft.",
|
||||||
|
link: `/projekte/${zettel.projectId}/stunden`,
|
||||||
|
projectId: zettel.projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${zettel.projectId}/stunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function aufmassPruefen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const measurementId = String(formData.get("measurementId"));
|
||||||
|
const aufmass = await prisma.measurement.findUniqueOrThrow({
|
||||||
|
where: { id: measurementId },
|
||||||
|
include: { zeilen: true },
|
||||||
|
});
|
||||||
|
const roles = await rolesInProject(user.id, aufmass.projectId);
|
||||||
|
assertCan(roles, "aufmass.pruefen");
|
||||||
|
|
||||||
|
let abweichung = false;
|
||||||
|
for (const z of aufmass.zeilen) {
|
||||||
|
const roh = formData.get(`menge_${z.id}`);
|
||||||
|
if (roh === null) continue;
|
||||||
|
const anerkannt = Number(String(roh).replace(",", "."));
|
||||||
|
if (!Number.isFinite(anerkannt) || anerkannt < 0) continue;
|
||||||
|
if (Math.abs(anerkannt - Number(z.menge)) > 0.001) abweichung = true;
|
||||||
|
await prisma.measurementLine.update({ where: { id: z.id }, data: { mengeAnerkannt: anerkannt } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entscheidung = String(formData.get("entscheidung"));
|
||||||
|
await prisma.measurement.update({
|
||||||
|
where: { id: measurementId },
|
||||||
|
data: {
|
||||||
|
status: entscheidung === "ABGELEHNT" ? "ABGELEHNT" : abweichung ? "TEILWEISE_ANERKANNT" : "ANERKANNT",
|
||||||
|
geprueftAm: new Date(),
|
||||||
|
bemerkung: (formData.get("bemerkung") as string) || aufmass.bemerkung,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id, aktion: "PRUEFUNG", entitaet: "Measurement",
|
||||||
|
entitaetId: measurementId, projectId: aufmass.projectId,
|
||||||
|
});
|
||||||
|
revalidatePath(`/projekte/${aufmass.projectId}/stunden`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function zettelEinreichen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const projectId = String(formData.get("projectId"));
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
assertCan(roles, "stunden.einreichen");
|
||||||
|
|
||||||
|
const contractId = String(formData.get("contractId"));
|
||||||
|
const anzahl = await prisma.timeSheet.count({ where: { contractId } });
|
||||||
|
|
||||||
|
const namen = formData.getAll("name").map(String);
|
||||||
|
const arten = formData.getAll("art").map(String);
|
||||||
|
const stunden = formData.getAll("stunden").map((s) => Number(String(s).replace(",", ".")));
|
||||||
|
const saetze = formData.getAll("satz").map((s) => Number(String(s).replace(",", ".")));
|
||||||
|
|
||||||
|
const zeilen = namen
|
||||||
|
.map((name, i) => ({
|
||||||
|
name,
|
||||||
|
art: (arten[i] || "FACHARBEITER") as never,
|
||||||
|
stundenGemeldet: Number.isFinite(stunden[i]) ? stunden[i] : 0,
|
||||||
|
stundensatz: Number.isFinite(saetze[i]) ? saetze[i] : 0,
|
||||||
|
}))
|
||||||
|
.filter((z) => z.name && z.stundenGemeldet > 0);
|
||||||
|
|
||||||
|
if (zeilen.length === 0) throw new Error("Es wurde keine Stundenzeile erfasst.");
|
||||||
|
|
||||||
|
const zettel = await prisma.timeSheet.create({
|
||||||
|
data: {
|
||||||
|
projectId,
|
||||||
|
contractId,
|
||||||
|
nummer: String(formData.get("nummer") || `SLZ-${new Date().getFullYear()}-${String(anzahl + 1).padStart(3, "0")}`),
|
||||||
|
datum: new Date(String(formData.get("datum"))),
|
||||||
|
leistung: String(formData.get("leistung")),
|
||||||
|
ortKm: (formData.get("ortKm") as string) || null,
|
||||||
|
bezugPosition: (formData.get("bezugPosition") as string) || null,
|
||||||
|
filedById: user.id,
|
||||||
|
zeilen: { create: zeilen },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pruefer = await prisma.projectMember.findMany({
|
||||||
|
where: { projectId, role: { in: ["BAUUEBERWACHER", "FACHBAUUEBERWACHER"] } },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: pruefer.map((p) => p.userId),
|
||||||
|
typ: "ZUWEISUNG",
|
||||||
|
titel: `Neuer Stundenlohnzettel ${zettel.nummer} zur Prüfung`,
|
||||||
|
text: String(formData.get("leistung")),
|
||||||
|
link: `/projekte/${projectId}/stunden`,
|
||||||
|
projectId,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/projekte/${projectId}/stunden`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Kachel, Leer } from "@/components/ui";
|
||||||
|
import { datum, eur, zahl } from "@/lib/format";
|
||||||
|
import { ZettelPruefung } from "./ZettelPruefung";
|
||||||
|
import { AufmassPruefung } from "./AufmassPruefung";
|
||||||
|
import { NeuerZettel } from "./NeuerZettel";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ART_LABEL: Record<string, string> = {
|
||||||
|
FACHARBEITER: "Facharbeiter",
|
||||||
|
VORARBEITER: "Vorarbeiter",
|
||||||
|
POLIER: "Polier",
|
||||||
|
HELFER: "Helfer",
|
||||||
|
MASCHINIST: "Maschinist",
|
||||||
|
SICHERUNGSPOSTEN: "Sicherungsposten",
|
||||||
|
GERAET: "Gerät",
|
||||||
|
SONSTIGES: "Sonstiges",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Stunden({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { user, roles, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
const darfPruefen = can(roles, "stunden.pruefen");
|
||||||
|
const darfEinreichen = can(roles, "stunden.einreichen");
|
||||||
|
|
||||||
|
// Datenauswahl hängt ausschließlich an der Rolle – ein Projektschalter
|
||||||
|
// darf niemals fremde Firmendaten sichtbar machen.
|
||||||
|
const nurEigene = nurEigeneFirma;
|
||||||
|
|
||||||
|
const [zettel, aufmasse, vertraege] = await Promise.all([
|
||||||
|
prisma.timeSheet.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
...(nurEigene && user.companyId ? { contract: { companyId: user.companyId } } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
zeilen: true,
|
||||||
|
contract: { include: { company: { select: { name: true, shortName: true } } } },
|
||||||
|
filedBy: { select: { name: true } },
|
||||||
|
checkedBy: { select: { name: true } },
|
||||||
|
diaryEntry: { select: { lfdNr: true, datum: true } },
|
||||||
|
invoiceLines: { select: { invoice: { select: { rechnungsNr: true, id: true } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ datum: "desc" }],
|
||||||
|
}),
|
||||||
|
prisma.measurement.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
...(nurEigene && user.companyId ? { contract: { companyId: user.companyId } } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
zeilen: { include: { contractItem: { select: { oz: true, kurztext: true, einheit: true, einheitspreis: true } } } },
|
||||||
|
contract: { select: { nummer: true, company: { select: { shortName: true, name: true } } } },
|
||||||
|
createdBy: { select: { name: true } },
|
||||||
|
},
|
||||||
|
orderBy: { datum: "desc" },
|
||||||
|
}),
|
||||||
|
prisma.contract.findMany({
|
||||||
|
where: { projectId: id, ...(nurEigene && user.companyId ? { companyId: user.companyId } : {}) },
|
||||||
|
select: { id: true, nummer: true, bezeichnung: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const offen = zettel.filter((z) => z.status === "EINGEREICHT");
|
||||||
|
const gemeldet = zettel.reduce((s, z) => s + z.zeilen.reduce((t, l) => t + Number(l.stundenGemeldet), 0), 0);
|
||||||
|
const anerkannt = zettel
|
||||||
|
.filter((z) => z.status === "ANERKANNT" || z.status === "TEILWEISE_ANERKANNT")
|
||||||
|
.reduce(
|
||||||
|
(s, z) => s + z.zeilen.reduce((t, l) => t + Number(l.stundenAnerkannt ?? l.stundenGemeldet), 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const wertAnerkannt = zettel
|
||||||
|
.filter((z) => z.status === "ANERKANNT" || z.status === "TEILWEISE_ANERKANNT")
|
||||||
|
.reduce(
|
||||||
|
(s, z) =>
|
||||||
|
s +
|
||||||
|
z.zeilen.reduce(
|
||||||
|
(t, l) =>
|
||||||
|
t + Number(l.stundenAnerkannt ?? l.stundenGemeldet) * Number(l.stundensatz) * (1 + Number(l.zuschlagProzent) / 100),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel titel="Zettel zu prüfen" wert={offen.length} ton={offen.length ? "warnung" : "gut"} hinweis={`${zettel.length} insgesamt`} />
|
||||||
|
<Kachel titel="Gemeldete Stunden" wert={zahl(gemeldet, 1)} hinweis="über alle Zettel" />
|
||||||
|
<Kachel
|
||||||
|
titel="Anerkannte Stunden"
|
||||||
|
wert={zahl(anerkannt, 1)}
|
||||||
|
hinweis={gemeldet > 0 ? `${Math.round((anerkannt / gemeldet) * 100)} % der gemeldeten` : undefined}
|
||||||
|
ton={anerkannt < gemeldet ? "warnung" : "gut"}
|
||||||
|
/>
|
||||||
|
<Kachel titel="Anerkannter Wert" wert={eur(wertAnerkannt)} hinweis="inkl. Zuschläge – Obergrenze für die Rechnung" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Stundenlohnzettel</h2>
|
||||||
|
{darfEinreichen && <NeuerZettel projectId={id} vertraege={vertraege} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{zettel.length === 0 ? (
|
||||||
|
<Leer text="Es wurden noch keine Stundenlohnzettel eingereicht." />
|
||||||
|
) : (
|
||||||
|
zettel.map((z) => {
|
||||||
|
const summeGemeldet = z.zeilen.reduce((s, l) => s + Number(l.stundenGemeldet), 0);
|
||||||
|
const summeAnerkannt = z.zeilen.reduce((s, l) => s + Number(l.stundenAnerkannt ?? 0), 0);
|
||||||
|
const wert = z.zeilen.reduce(
|
||||||
|
(s, l) =>
|
||||||
|
s +
|
||||||
|
Number(l.stundenAnerkannt ?? l.stundenGemeldet) *
|
||||||
|
Number(l.stundensatz) *
|
||||||
|
(1 + Number(l.zuschlagProzent) / 100),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article key={z.id} className="card overflow-hidden">
|
||||||
|
<header className="flex flex-wrap items-center gap-3 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-xs text-db-muted">{z.nummer}</span>
|
||||||
|
<span className="text-sm text-slate-100">{datum(z.datum)}</span>
|
||||||
|
<StatusChip status={z.status} />
|
||||||
|
<span className="text-xs text-db-muted">
|
||||||
|
{z.contract.company.shortName ?? z.contract.company.name} · Vertrag {z.contract.nummer}
|
||||||
|
</span>
|
||||||
|
{z.diaryEntry && (
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-400">
|
||||||
|
Bautagebuch Nr. {z.diaryEntry.lfdNr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{z.invoiceLines.length > 0 && (
|
||||||
|
<span className="chip border-indigo-800 bg-indigo-950/40 text-indigo-300">
|
||||||
|
abgerechnet mit {z.invoiceLines[0].invoice.rechnungsNr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="ml-auto text-right text-xs">
|
||||||
|
<div className="tabular-nums text-slate-300">
|
||||||
|
{zahl(summeGemeldet, 1)} Std. gemeldet
|
||||||
|
{z.status !== "EINGEREICHT" && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<span className={summeAnerkannt < summeGemeldet ? "text-amber-400" : "text-emerald-400"}>
|
||||||
|
{zahl(summeAnerkannt, 1)} anerkannt
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="tabular-nums text-db-muted">{eur(wert)}</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm text-slate-300">{z.leistung}</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-db-muted">
|
||||||
|
eingereicht von {z.filedBy.name}
|
||||||
|
{z.ortKm && ` · km ${z.ortKm}`}
|
||||||
|
{z.bezugPosition && ` · Bezug OZ ${z.bezugPosition}`}
|
||||||
|
{z.checkedBy && ` · geprüft von ${z.checkedBy.name} am ${datum(z.geprueftAm)}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{z.pruefkommentar && (
|
||||||
|
<p className="mt-2 rounded border-l-2 border-db-cyan/50 bg-db-dark/50 py-1.5 pl-2.5 text-xs leading-relaxed text-slate-400">
|
||||||
|
<span className="text-db-cyan">Prüfvermerk:</span> {z.pruefkommentar}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{darfPruefen && z.status === "EINGEREICHT" ? (
|
||||||
|
<ZettelPruefung
|
||||||
|
timeSheetId={z.id}
|
||||||
|
zeilen={z.zeilen.map((l) => ({
|
||||||
|
id: l.id,
|
||||||
|
name: l.name ?? "",
|
||||||
|
art: ART_LABEL[l.art] ?? l.art,
|
||||||
|
gemeldet: Number(l.stundenGemeldet),
|
||||||
|
satz: Number(l.stundensatz),
|
||||||
|
zuschlag: Number(l.zuschlagProzent),
|
||||||
|
zuschlagGrund: l.zuschlagGrund,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="mt-3 w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-db-line/60">
|
||||||
|
<th className="th">Name / Gerät</th>
|
||||||
|
<th className="th">Art</th>
|
||||||
|
<th className="th text-right">gemeldet</th>
|
||||||
|
<th className="th text-right">anerkannt</th>
|
||||||
|
<th className="th text-right">Satz</th>
|
||||||
|
<th className="th text-right">Zuschlag</th>
|
||||||
|
<th className="th text-right">Wert</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{z.zeilen.map((l) => {
|
||||||
|
const std = Number(l.stundenAnerkannt ?? l.stundenGemeldet);
|
||||||
|
const gekuerzt = l.stundenAnerkannt !== null && Number(l.stundenAnerkannt) < Number(l.stundenGemeldet);
|
||||||
|
return (
|
||||||
|
<tr key={l.id} className="row">
|
||||||
|
<td className="td text-slate-200">{l.name}</td>
|
||||||
|
<td className="td text-xs text-db-muted">{ART_LABEL[l.art]}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{zahl(l.stundenGemeldet, 2)}</td>
|
||||||
|
<td className={`td text-right tabular-nums ${gekuerzt ? "text-amber-400" : "text-slate-300"}`}>
|
||||||
|
{l.stundenAnerkannt !== null ? zahl(l.stundenAnerkannt, 2) : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{eur(l.stundensatz)}</td>
|
||||||
|
<td className="td text-right text-xs text-db-muted">
|
||||||
|
{Number(l.zuschlagProzent) > 0 ? (
|
||||||
|
<span title={l.zuschlagGrund ?? ""}>+{zahl(l.zuschlagProzent, 0)} %</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-200">
|
||||||
|
{eur(std * Number(l.stundensatz) * (1 + Number(l.zuschlagProzent) / 100))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Aufmaße und Abrechnungszeichnungen</h2>
|
||||||
|
{aufmasse.length === 0 ? (
|
||||||
|
<Leer text="Es liegen keine Aufmaße vor." />
|
||||||
|
) : (
|
||||||
|
aufmasse.map((a) => {
|
||||||
|
const wert = a.zeilen.reduce(
|
||||||
|
(s, z) => s + Number(z.mengeAnerkannt ?? z.menge) * Number(z.contractItem?.einheitspreis ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<article key={a.id} className="card overflow-hidden">
|
||||||
|
<header className="flex flex-wrap items-center gap-3 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
|
||||||
|
<span className="font-mono text-xs text-db-muted">{a.nummer}</span>
|
||||||
|
<span className="text-sm text-slate-100">{datum(a.datum)}</span>
|
||||||
|
<StatusChip status={a.status} />
|
||||||
|
<span className="text-xs text-db-muted">
|
||||||
|
{a.art.replace(/_/g, " ").toLowerCase()} · {a.ortBauteil} · {a.contract.company.shortName}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto text-xs tabular-nums text-slate-300">{eur(wert)} anerkannt</span>
|
||||||
|
</header>
|
||||||
|
<div className="p-4">
|
||||||
|
{a.bemerkung && <p className="mb-2 text-xs text-db-muted">{a.bemerkung}</p>}
|
||||||
|
{can(roles, "aufmass.pruefen") && a.status === "EINGEREICHT" ? (
|
||||||
|
<AufmassPruefung
|
||||||
|
measurementId={a.id}
|
||||||
|
zeilen={a.zeilen.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
oz: z.contractItem?.oz ?? "—",
|
||||||
|
text: z.beschreibung ?? z.contractItem?.kurztext ?? "",
|
||||||
|
formel: z.formel ?? "",
|
||||||
|
einheit: z.contractItem?.einheit ?? "",
|
||||||
|
menge: Number(z.menge),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-db-line/60">
|
||||||
|
<th className="th">OZ</th>
|
||||||
|
<th className="th">Beschreibung</th>
|
||||||
|
<th className="th">Berechnung</th>
|
||||||
|
<th className="th text-right">Menge</th>
|
||||||
|
<th className="th text-right">anerkannt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{a.zeilen.map((z) => {
|
||||||
|
const gekuerzt = z.mengeAnerkannt !== null && Number(z.mengeAnerkannt) < Number(z.menge);
|
||||||
|
return (
|
||||||
|
<tr key={z.id} className="row">
|
||||||
|
<td className="td font-mono text-xs text-db-muted">{z.contractItem?.oz}</td>
|
||||||
|
<td className="td text-slate-200">{z.beschreibung ?? z.contractItem?.kurztext}</td>
|
||||||
|
<td className="td font-mono text-xs text-db-muted">{z.formel}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">
|
||||||
|
{zahl(z.menge, 2)} {z.contractItem?.einheit}
|
||||||
|
</td>
|
||||||
|
<td className={`td text-right tabular-nums ${gekuerzt ? "text-amber-400" : "text-slate-300"}`}>
|
||||||
|
{z.mengeAnerkannt !== null ? zahl(z.mengeAnerkannt, 2) : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { projektKontext } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { canInProject } from "@/lib/rbac";
|
||||||
|
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
||||||
|
import { datum, eur, zahl } from "@/lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Vertraege({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
if (!canInProject(roles, "vertrag.lesen", optionen))
|
||||||
|
return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />;
|
||||||
|
|
||||||
|
const vertraege = await prisma.contract.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: id,
|
||||||
|
// Auftragnehmer sehen ausschließlich den eigenen Vertrag
|
||||||
|
...(nurEigeneFirma ? { companyId: user.companyId ?? "__keine__" } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
company: true,
|
||||||
|
positionen: { orderBy: { oz: "asc" } },
|
||||||
|
nachtraege: { orderBy: { nummer: "asc" } },
|
||||||
|
invoices: { select: { nettoBetrag: true, anerkanntNetto: true, status: true } },
|
||||||
|
_count: { select: { timeSheets: true, measurements: true } },
|
||||||
|
},
|
||||||
|
orderBy: { nummer: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vertraege.length === 0) return <Leer text="Für dieses Projekt sind keine Verträge erfasst." />;
|
||||||
|
|
||||||
|
const gesamtAuftrag = vertraege.reduce((s, v) => s + Number(v.auftragssumme), 0);
|
||||||
|
const gesamtNachtrag = vertraege
|
||||||
|
.flatMap((v) => v.nachtraege)
|
||||||
|
.filter((n) => n.status === "BEAUFTRAGT")
|
||||||
|
.reduce((s, n) => s + Number(n.anerkannteSumme ?? n.angebotssumme ?? 0), 0);
|
||||||
|
const offeneNachtraege = vertraege
|
||||||
|
.flatMap((v) => v.nachtraege)
|
||||||
|
.filter((n) => n.status !== "BEAUFTRAGT" && n.status !== "ABGELEHNT");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel titel="Auftragssummen" wert={eur(gesamtAuftrag)} hinweis={`${vertraege.length} Verträge`} />
|
||||||
|
<Kachel titel="Beauftragte Nachträge" wert={eur(gesamtNachtrag)} hinweis={`${((gesamtNachtrag / Math.max(gesamtAuftrag, 1)) * 100).toFixed(1)} % der Auftragssumme`} ton={gesamtNachtrag / Math.max(gesamtAuftrag, 1) > 0.1 ? "warnung" : "neutral"} />
|
||||||
|
<Kachel titel="Nachträge in Prüfung" wert={offeneNachtraege.length} hinweis={eur(offeneNachtraege.reduce((s, n) => s + Number(n.angebotssumme ?? 0), 0))} ton={offeneNachtraege.length ? "warnung" : "gut"} />
|
||||||
|
<Kachel titel="Vertragsvolumen gesamt" wert={eur(gesamtAuftrag + gesamtNachtrag)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vertraege.map((v) => {
|
||||||
|
const nachtragSumme = v.nachtraege
|
||||||
|
.filter((n) => n.status === "BEAUFTRAGT")
|
||||||
|
.reduce((s, n) => s + Number(n.anerkannteSumme ?? n.angebotssumme ?? 0), 0);
|
||||||
|
const rahmen = Number(v.auftragssumme) + nachtragSumme;
|
||||||
|
const abgerechnet = v.invoices
|
||||||
|
.filter((i) => i.status !== "ZURUECKGEWIESEN")
|
||||||
|
.reduce((s, i) => s + Number(i.anerkanntNetto ?? i.nettoBetrag), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section key={v.id} className="card overflow-hidden">
|
||||||
|
<header className="border-b border-db-line bg-db-dark/40 px-4 py-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-db-muted">{v.nummer}</span>
|
||||||
|
<StatusChip status={v.status} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-0.5 text-sm font-semibold text-white">{v.bezeichnung}</h3>
|
||||||
|
<p className="text-xs text-db-muted">
|
||||||
|
{v.company.name}
|
||||||
|
{v.company.kreditorNr && ` · Kreditor ${v.company.kreditorNr}`}
|
||||||
|
{v.vergabeart && ` · ${v.vergabeart}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<dl className="flex gap-6 text-xs">
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Auftragssumme</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{eur(v.auftragssumme)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">inkl. Nachträge</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">{eur(rahmen)}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">abgerechnet</dt>
|
||||||
|
<dd className={`mt-0.5 tabular-nums ${abgerechnet > rahmen ? "text-red-400" : "text-slate-200"}`}>
|
||||||
|
{eur(abgerechnet)} ({rahmen ? Math.round((abgerechnet / rahmen) * 100) : 0} %)
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-db-muted">Ausführung</dt>
|
||||||
|
<dd className="mt-0.5 tabular-nums text-slate-200">
|
||||||
|
{datum(v.ausfuehrungVon)} – {datum(v.ausfuehrungBis)}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-db-muted">
|
||||||
|
Zahlungsziel {v.zahlungszielTage} Tage · Skonto {zahl(v.skontoProzent)} % bei {v.skontoTage} Tagen ·
|
||||||
|
Sicherheitseinbehalt {zahl(v.sicherheitseinbehaltProzent)} % · Gewährleistung {v.gewaehrleistungJahre} Jahre ·
|
||||||
|
{v._count.measurements} Aufmaße · {v._count.timeSheets} Stundenlohnzettel
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-0 lg:grid-cols-2">
|
||||||
|
<div className="border-b border-db-line lg:border-b-0 lg:border-r">
|
||||||
|
<h4 className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-db-muted">
|
||||||
|
Leistungsverzeichnis
|
||||||
|
</h4>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-y border-db-line/60">
|
||||||
|
<th className="th">OZ</th>
|
||||||
|
<th className="th">Kurztext</th>
|
||||||
|
<th className="th text-right">Menge</th>
|
||||||
|
<th className="th text-right">EP</th>
|
||||||
|
<th className="th text-right">GP</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{v.positionen.map((p) => (
|
||||||
|
<tr key={p.id} className="row">
|
||||||
|
<td className="td whitespace-nowrap font-mono text-xs text-db-muted">{p.oz}</td>
|
||||||
|
<td className="td text-slate-200">
|
||||||
|
{p.kurztext}
|
||||||
|
{p.istStundenlohn && (
|
||||||
|
<span className="ml-1.5 chip border-db-line bg-db-dark text-[10px] text-slate-400">
|
||||||
|
Stundenlohn
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-300">
|
||||||
|
{zahl(p.menge, 0)} {p.einheit}
|
||||||
|
</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-300">{eur(p.einheitspreis)}</td>
|
||||||
|
<td className="td whitespace-nowrap text-right tabular-nums text-slate-200">
|
||||||
|
{eur(Number(p.menge) * Number(p.einheitspreis))}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="px-4 py-2 text-xs font-semibold uppercase tracking-wider text-db-muted">Nachträge</h4>
|
||||||
|
{v.nachtraege.length === 0 ? (
|
||||||
|
<p className="px-4 py-4 text-sm text-db-muted">Keine Nachträge.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-db-line/70 border-t border-db-line/60">
|
||||||
|
{v.nachtraege.map((n) => (
|
||||||
|
<li key={n.id} className="px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs text-db-muted">{n.nummer}</span>
|
||||||
|
<StatusChip status={n.status} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-sm text-slate-200">{n.titel}</p>
|
||||||
|
{n.begruendung && (
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-db-muted">{n.begruendung}</p>
|
||||||
|
)}
|
||||||
|
{n.stellungnahme && (
|
||||||
|
<p className="mt-1.5 rounded border-l-2 border-db-cyan/50 bg-db-dark/50 py-1 pl-2 text-xs leading-relaxed text-slate-400">
|
||||||
|
<span className="text-db-cyan">Stellungnahme Bauüberwachung:</span> {n.stellungnahme}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 text-[11px] text-db-muted">
|
||||||
|
{n.rechtsgrundlage}
|
||||||
|
{n.angemeldetAm && ` · angemeldet ${datum(n.angemeldetAm)}`}
|
||||||
|
{n.beauftragtAm && ` · beauftragt ${datum(n.beauftragtAm)}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 text-right text-xs">
|
||||||
|
<div className="tabular-nums text-slate-400">Angebot {eur(n.angebotssumme)}</div>
|
||||||
|
{n.anerkannteSumme !== null && (
|
||||||
|
<div className="tabular-nums font-medium text-emerald-400">
|
||||||
|
anerkannt {eur(n.anerkannteSumme)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { Plus, Trash2, FolderTree } from "lucide-react";
|
||||||
|
import { projektAnlegen } from "./actions";
|
||||||
|
import type { ProjektOptionen } from "@/lib/rbac";
|
||||||
|
|
||||||
|
type Person = { id: string; name: string; position: string | null; firma: string | null; extern: boolean };
|
||||||
|
type OptionInfo = { feld: keyof ProjektOptionen; titel: string; hinweis: string; standard: boolean };
|
||||||
|
|
||||||
|
export function ProjektFormular({
|
||||||
|
personen,
|
||||||
|
rollen,
|
||||||
|
fachbereicheNetz,
|
||||||
|
fachbereicheBahnhof,
|
||||||
|
masken,
|
||||||
|
optionen,
|
||||||
|
standardOptionen,
|
||||||
|
eigeneId,
|
||||||
|
}: {
|
||||||
|
personen: Person[];
|
||||||
|
rollen: { wert: string; label: string }[];
|
||||||
|
fachbereicheNetz: string[];
|
||||||
|
fachbereicheBahnhof: string[];
|
||||||
|
masken: { netz: number[]; bahnhof: number[] };
|
||||||
|
optionen: OptionInfo[];
|
||||||
|
standardOptionen: ProjektOptionen;
|
||||||
|
eigeneId: string;
|
||||||
|
}) {
|
||||||
|
const [bauherr, setBauherr] = useState("DB_INFRAGO_FAHRWEG");
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<string[]>([]);
|
||||||
|
const [abschnitte, setAbschnitte] = useState([0]);
|
||||||
|
const [mitglieder, setMitglieder] = useState<{ key: number; userId: string; role: string }[]>([]);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const istBahnhof = bauherr === "DB_INFRAGO_BAHNHOF";
|
||||||
|
const liste = istBahnhof ? fachbereicheBahnhof : fachbereicheNetz;
|
||||||
|
const versatz = istBahnhof ? fachbereicheNetz.length : 0;
|
||||||
|
|
||||||
|
// Vorschau: wie viele Soll-Unterlagen bringt die aktuelle Auswahl mit?
|
||||||
|
const anzahl = useMemo(() => {
|
||||||
|
const relevanteMasken = istBahnhof ? masken.bahnhof : masken.netz;
|
||||||
|
if (gewaehlt.length === 0) return 0;
|
||||||
|
const auswahl = gewaehlt.reduce((m, f) => {
|
||||||
|
const i = liste.indexOf(f);
|
||||||
|
return i < 0 ? m : m | (1 << (i + versatz));
|
||||||
|
}, 0);
|
||||||
|
// Ohne Fachbereichszuordnung = gilt generell
|
||||||
|
return relevanteMasken.filter((m) => m === 0 || (m & auswahl) !== 0).length;
|
||||||
|
}, [gewaehlt, istBahnhof, masken, liste, versatz]);
|
||||||
|
|
||||||
|
const toggle = (f: string) =>
|
||||||
|
setGewaehlt((g) => (g.includes(f) ? g.filter((x) => x !== f) : [...g, f]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="space-y-5"
|
||||||
|
action={(fd) => {
|
||||||
|
setFehler(null);
|
||||||
|
start(async () => {
|
||||||
|
try {
|
||||||
|
await projektAnlegen(fd);
|
||||||
|
} catch (e) {
|
||||||
|
const m = e instanceof Error ? e.message : "Das Projekt konnte nicht angelegt werden.";
|
||||||
|
// Weiterleitungen von Next nicht als Fehler anzeigen
|
||||||
|
if (!m.includes("NEXT_REDIRECT")) setFehler(m);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ── Stammdaten ───────────────────────────────────────── */}
|
||||||
|
<section className="card space-y-4 p-4">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Stammdaten</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Projektnummer / PSP</label>
|
||||||
|
<input name="nummer" required placeholder="P-2026-0148" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="label mb-1.5 block">Bezeichnung</label>
|
||||||
|
<input name="name" required placeholder="Streckenertüchtigung …" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Kurzname</label>
|
||||||
|
<input name="kurzname" placeholder="für Listen und Kacheln" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Bauherrenseite</label>
|
||||||
|
<select name="bauherr" value={bauherr} onChange={(e) => { setBauherr(e.target.value); setGewaehlt([]); }} className="input">
|
||||||
|
<option value="DB_INFRAGO_FAHRWEG">DB InfraGO – Fahrweg</option>
|
||||||
|
<option value="DB_INFRAGO_BAHNHOF">DB InfraGO – Personenbahnhöfe</option>
|
||||||
|
<option value="DB_ENERGIE">DB Energie</option>
|
||||||
|
<option value="SONSTIGE">Sonstige</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[11px] text-db-muted">
|
||||||
|
Entscheidet, welche Spalte der Ablagestruktur gilt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Status</label>
|
||||||
|
<select name="status" defaultValue="ANLAGE" className="input">
|
||||||
|
{["ANLAGE", "PLANUNG", "VERGABE", "BAU", "INBETRIEBNAHME", "ABNAHME", "GEWAEHRLEISTUNG", "RUHEND"].map((s) => (
|
||||||
|
<option key={s} value={s}>{s.charAt(0) + s.slice(1).toLowerCase()}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Regionalbereich</label>
|
||||||
|
<input name="regionalbereich" placeholder="Nord" className="input" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Budget (EUR)</label>
|
||||||
|
<input name="budgetPlan" inputMode="decimal" className="input" />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="label mb-1.5 block">Beschreibung</label>
|
||||||
|
<textarea name="beschreibung" rows={3} className="input" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Fachbereiche ─────────────────────────────────────── */}
|
||||||
|
<section className="card space-y-4 p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Fachbereiche</h2>
|
||||||
|
<p className="mt-0.5 max-w-xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Bestimmt, welche Unterlagen der Ablagestruktur für dieses Projekt gelten.
|
||||||
|
Im Zweifel großzügig wählen – nicht zutreffende Positionen lassen sich später
|
||||||
|
einzeln auf „entfällt“ setzen, was nachvollziehbarer ist als eine Lücke.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 rounded-md border border-db-line bg-db-dark px-4 py-2.5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5 text-[11px] uppercase tracking-wider text-db-muted">
|
||||||
|
<FolderTree size={12} /> Soll-Unterlagen
|
||||||
|
</div>
|
||||||
|
<div className={`mt-0.5 text-2xl font-semibold tabular-nums ${anzahl ? "text-db-cyan" : "text-slate-600"}`}>
|
||||||
|
{anzahl}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{liste.map((f) => {
|
||||||
|
const an = gewaehlt.includes(f);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={f}
|
||||||
|
className={`flex cursor-pointer items-start gap-2.5 rounded-md border p-2.5 text-sm transition ${
|
||||||
|
an ? "border-db-cyan bg-db-cyan/10 text-slate-100" : "border-db-line text-slate-300 hover:border-slate-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="fachbereiche"
|
||||||
|
value={f}
|
||||||
|
checked={an}
|
||||||
|
onChange={() => toggle(f)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span>{f}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setGewaehlt(liste)} className="btn-ghost btn-sm">
|
||||||
|
alle wählen
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setGewaehlt([])} className="btn-ghost btn-sm">
|
||||||
|
Auswahl leeren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Termine ──────────────────────────────────────────── */}
|
||||||
|
<section className="card space-y-4 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Termine</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
Daraus werden Meilensteine und die Fristen der Soll-Unterlagen berechnet. Was jetzt
|
||||||
|
noch offen ist, kann später ergänzt werden – die Fristen werden dann nachgezogen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
["startgespraech", "Startgespräch"],
|
||||||
|
["baubeginn", "Baubeginn"],
|
||||||
|
["inbetriebnahme", "Inbetriebnahme (IBN)"],
|
||||||
|
["bauende", "Bauende"],
|
||||||
|
["bauschlussmeldung", "Bauschlussmeldung"],
|
||||||
|
["projektabschluss", "Projektabschluss (PA)"],
|
||||||
|
].map(([feld, label]) => (
|
||||||
|
<div key={feld}>
|
||||||
|
<label className="label mb-1.5 block">{label}</label>
|
||||||
|
<input type="date" name={feld} className="input" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Streckenabschnitte ───────────────────────────────── */}
|
||||||
|
<section className="card space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-white">Streckenabschnitte</h2>
|
||||||
|
<button type="button" onClick={() => setAbschnitte([...abschnitte, Date.now()])} className="btn-ghost btn-sm">
|
||||||
|
<Plus size={12} /> Abschnitt
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{abschnitte.map((k) => (
|
||||||
|
<div key={k} className="grid gap-2 sm:grid-cols-[110px_1fr_90px_90px_150px_32px]">
|
||||||
|
<input name="streckenNr" placeholder="Strecke" className="input py-1.5 text-xs" />
|
||||||
|
<input name="streckenName" placeholder="Streckenname" className="input py-1.5 text-xs" />
|
||||||
|
<input name="kmVon" placeholder="km von" className="input py-1.5 text-xs" />
|
||||||
|
<input name="kmBis" placeholder="km bis" className="input py-1.5 text-xs" />
|
||||||
|
<input name="betriebsstelle" placeholder="Betriebsstelle" className="input py-1.5 text-xs" />
|
||||||
|
{abschnitte.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAbschnitte(abschnitte.filter((x) => x !== k))}
|
||||||
|
className="p-1 text-db-muted hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Beteiligte ───────────────────────────────────────── */}
|
||||||
|
<section className="card space-y-3 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Beteiligte</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-db-muted">
|
||||||
|
Sie selbst werden automatisch als Projektleitung aufgenommen. Wer hier nicht steht,
|
||||||
|
sieht das Projekt nicht.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMitglieder([...mitglieder, { key: Date.now(), userId: "", role: "LESER" }])}
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<Plus size={12} /> Person
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mitglieder.length === 0 ? (
|
||||||
|
<p className="text-sm text-db-muted">Noch niemand hinzugefügt.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mitglieder.map((m, i) => (
|
||||||
|
<div key={m.key} className="grid gap-2 sm:grid-cols-[1fr_260px_32px]">
|
||||||
|
<select
|
||||||
|
name="mitgliedId"
|
||||||
|
value={m.userId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const kopie = [...mitglieder];
|
||||||
|
kopie[i] = { ...m, userId: e.target.value };
|
||||||
|
setMitglieder(kopie);
|
||||||
|
}}
|
||||||
|
className="input py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
<option value="">— Person wählen —</option>
|
||||||
|
{personen
|
||||||
|
.filter((p) => p.id !== eigeneId)
|
||||||
|
.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name}
|
||||||
|
{p.firma ? ` · ${p.firma}` : ""}
|
||||||
|
{p.position ? ` · ${p.position}` : ""}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
name="mitgliedRolle"
|
||||||
|
value={m.role}
|
||||||
|
onChange={(e) => {
|
||||||
|
const kopie = [...mitglieder];
|
||||||
|
kopie[i] = { ...m, role: e.target.value };
|
||||||
|
setMitglieder(kopie);
|
||||||
|
}}
|
||||||
|
className="input py-1.5 text-xs"
|
||||||
|
>
|
||||||
|
{rollen.map((r) => (
|
||||||
|
<option key={r.wert} value={r.wert}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMitglieder(mitglieder.filter((x) => x.key !== m.key))}
|
||||||
|
className="p-1 text-db-muted hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Einstellungen ────────────────────────────────────── */}
|
||||||
|
<section className="card space-y-3 p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Was Auftragnehmer sehen dürfen</h2>
|
||||||
|
<p className="mt-0.5 max-w-2xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Baufirmen sehen grundsätzlich ausschließlich Daten der eigenen Firma – das ist fest
|
||||||
|
verdrahtet. Diese Schalter entscheiden nur, welche Bereiche ihnen überhaupt angezeigt
|
||||||
|
werden. Jederzeit änderbar unter „Einstellungen“ im Projekt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{optionen.map((o) => (
|
||||||
|
<label
|
||||||
|
key={o.feld}
|
||||||
|
className="flex cursor-pointer items-start gap-3 rounded-md border border-db-line p-3 transition hover:border-slate-500"
|
||||||
|
>
|
||||||
|
<input type="checkbox" name={o.feld} defaultChecked={standardOptionen[o.feld]} className="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-slate-100">{o.titel}</span>
|
||||||
|
<span className="mt-0.5 block max-w-2xl text-xs leading-relaxed text-db-muted">{o.hinweis}</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<label className="flex cursor-pointer items-start gap-3 rounded-md border border-db-line p-3 transition hover:border-slate-500">
|
||||||
|
<input type="checkbox" name="freigabeTrotzBefund" defaultChecked className="mt-1" />
|
||||||
|
<span>
|
||||||
|
<span className="block text-sm font-medium text-slate-100">
|
||||||
|
Sachliche Bescheinigung trotz kritischer Prüfbefunde zulassen
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 block max-w-2xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Erlaubt der Bauüberwachung, eine Rechnung auch dann sachlich zu bescheinigen, wenn
|
||||||
|
der Abgleich kritische Befunde ergeben hat – nur mit Begründung und protokolliert.
|
||||||
|
Abgeschaltet müssen erst alle Befunde geklärt werden.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" disabled={pending || gewaehlt.length === 0} className="btn-primary">
|
||||||
|
{pending ? "wird angelegt …" : "Projekt anlegen"}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-db-muted">
|
||||||
|
{gewaehlt.length === 0
|
||||||
|
? "Bitte mindestens einen Fachbereich wählen."
|
||||||
|
: `${anzahl} Soll-Unterlagen werden für dieses Projekt angelegt.`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { can } from "@/lib/rbac";
|
||||||
|
import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte";
|
||||||
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
|
import type { Bauherr, ProjectStatus, Role } from "@prisma/client";
|
||||||
|
|
||||||
|
function datumOderNull(v: FormDataEntryValue | null): Date | null {
|
||||||
|
const s = String(v ?? "").trim();
|
||||||
|
return s ? new Date(s) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function projektAnlegen(formData: FormData) {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
// Das Recht ist global – wer irgendwo Projektleitung ist, darf anlegen.
|
||||||
|
const rollen = await prisma.projectMember.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
const globaleRollen: Role[] = [...new Set(rollen.map((r) => r.role))];
|
||||||
|
if (user.isAdmin) globaleRollen.push("ADMIN");
|
||||||
|
if (!can(globaleRollen, "projekt.anlegen")) {
|
||||||
|
throw new Error("Projekte dürfen nur Projektleitung und Administration anlegen.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nummer = String(formData.get("nummer") ?? "").trim();
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
if (!nummer || !name) throw new Error("Projektnummer und Bezeichnung sind erforderlich.");
|
||||||
|
|
||||||
|
if (await prisma.project.findUnique({ where: { nummer } })) {
|
||||||
|
throw new Error(`Die Projektnummer „${nummer}“ ist bereits vergeben.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
|
||||||
|
if (fachbereiche.length === 0) {
|
||||||
|
throw new Error(
|
||||||
|
"Bitte mindestens einen Fachbereich wählen – daraus ergibt sich, welche Unterlagen der Ablagestruktur einschlägig sind.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const termine = {
|
||||||
|
startgespraech: datumOderNull(formData.get("startgespraech")),
|
||||||
|
baubeginn: datumOderNull(formData.get("baubeginn")),
|
||||||
|
bauende: datumOderNull(formData.get("bauende")),
|
||||||
|
inbetriebnahme: datumOderNull(formData.get("inbetriebnahme")),
|
||||||
|
bauschlussmeldung: datumOderNull(formData.get("bauschlussmeldung")),
|
||||||
|
projektabschluss: datumOderNull(formData.get("projektabschluss")),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Streckenabschnitte (beliebig viele Zeilen aus dem Formular)
|
||||||
|
const streckenNr = formData.getAll("streckenNr").map(String);
|
||||||
|
const abschnitte = streckenNr
|
||||||
|
.map((nr, i) => ({
|
||||||
|
streckenNr: nr.trim() || null,
|
||||||
|
streckenName: String(formData.getAll("streckenName")[i] ?? "").trim() || null,
|
||||||
|
kmVon: String(formData.getAll("kmVon")[i] ?? "").trim() || null,
|
||||||
|
kmBis: String(formData.getAll("kmBis")[i] ?? "").trim() || null,
|
||||||
|
betriebsstelle: String(formData.getAll("betriebsstelle")[i] ?? "").trim() || null,
|
||||||
|
}))
|
||||||
|
.filter((a) => a.streckenNr || a.betriebsstelle || a.kmVon);
|
||||||
|
|
||||||
|
// Mitglieder
|
||||||
|
const mitgliedIds = formData.getAll("mitgliedId").map(String);
|
||||||
|
const mitgliedRollen = formData.getAll("mitgliedRolle").map(String);
|
||||||
|
const mitglieder = mitgliedIds
|
||||||
|
.map((uid, i) => ({ userId: uid, role: (mitgliedRollen[i] || "LESER") as Role }))
|
||||||
|
.filter((m) => m.userId);
|
||||||
|
|
||||||
|
// Anlegende Person immer als Projektleitung aufnehmen, sonst wäre das
|
||||||
|
// Projekt unmittelbar nach dem Anlegen für sie unsichtbar.
|
||||||
|
if (!mitglieder.some((m) => m.userId === user.id)) {
|
||||||
|
mitglieder.unshift({ userId: user.id, role: user.isAdmin ? "ADMIN" : "PROJEKTLEITER" });
|
||||||
|
}
|
||||||
|
const eindeutig = [...new Map(mitglieder.map((m) => [`${m.userId}|${m.role}`, m])).values()];
|
||||||
|
|
||||||
|
const projekt = await prisma.project.create({
|
||||||
|
data: {
|
||||||
|
nummer,
|
||||||
|
name,
|
||||||
|
kurzname: String(formData.get("kurzname") ?? "").trim() || null,
|
||||||
|
bauherr: (formData.get("bauherr") as Bauherr) || "DB_INFRAGO_FAHRWEG",
|
||||||
|
status: (formData.get("status") as ProjectStatus) || "ANLAGE",
|
||||||
|
regionalbereich: String(formData.get("regionalbereich") ?? "").trim() || null,
|
||||||
|
beschreibung: String(formData.get("beschreibung") ?? "").trim() || null,
|
||||||
|
fachbereiche,
|
||||||
|
budgetPlan: formData.get("budgetPlan") ? Number(String(formData.get("budgetPlan")).replace(",", ".")) : null,
|
||||||
|
...termine,
|
||||||
|
|
||||||
|
anSichtVertrag: formData.get("anSichtVertrag") === "on",
|
||||||
|
anSichtRechnungen: formData.get("anSichtRechnungen") === "on",
|
||||||
|
anSichtBautagebuch: formData.get("anSichtBautagebuch") === "on",
|
||||||
|
anSichtDokumentation: formData.get("anSichtDokumentation") === "on",
|
||||||
|
anDarfDokumente: formData.get("anDarfDokumente") === "on",
|
||||||
|
freigabeTrotzBefund: formData.get("freigabeTrotzBefund") === "on",
|
||||||
|
|
||||||
|
abschnitte: abschnitte.length ? { create: abschnitte } : undefined,
|
||||||
|
milestones: { create: meilensteineAusTerminen(termine) },
|
||||||
|
members: { create: eindeutig },
|
||||||
|
channels: {
|
||||||
|
create: {
|
||||||
|
name: "Baustelle allgemein",
|
||||||
|
kind: "PROJEKT",
|
||||||
|
beschreibung: "Tagesabstimmung aller Projektbeteiligten.",
|
||||||
|
mitglieder: { create: [...new Set(eindeutig.map((m) => m.userId))].map((userId) => ({ userId })) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { neu } = await slotsErzeugen(projekt.id);
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id,
|
||||||
|
aktion: "CREATE",
|
||||||
|
entitaet: "Project",
|
||||||
|
entitaetId: projekt.id,
|
||||||
|
projectId: projekt.id,
|
||||||
|
details: { nummer, fachbereiche, sollUnterlagen: neu },
|
||||||
|
});
|
||||||
|
|
||||||
|
await benachrichtige({
|
||||||
|
userIds: eindeutig.map((m) => m.userId),
|
||||||
|
typ: "SYSTEM",
|
||||||
|
titel: `Sie wurden dem Projekt ${nummer} zugeordnet`,
|
||||||
|
text: `${name}\n\n${neu} Soll-Unterlagen aus der EIU-Ablagestruktur wurden für dieses Projekt angelegt.`,
|
||||||
|
link: `/projekte/${projekt.id}`,
|
||||||
|
projectId: projekt.id,
|
||||||
|
ausgeloestVon: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/projekte");
|
||||||
|
redirect(`/projekte/${projekt.id}?angelegt=${neu}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { can, ROLE_LABEL, OPTION_INFO, type ProjektOptionen } from "@/lib/rbac";
|
||||||
|
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
||||||
|
import { fachbereichsMasken } from "@/lib/projekte";
|
||||||
|
import { ProjektFormular } from "./ProjektFormular";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function NeuesProjekt() {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
const eigene = await prisma.projectMember.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
const rollen: Role[] = [...new Set(eigene.map((r) => r.role))];
|
||||||
|
if (user.isAdmin) rollen.push("ADMIN");
|
||||||
|
if (!can(rollen, "projekt.anlegen")) redirect("/projekte?fehler=kein-zugriff");
|
||||||
|
|
||||||
|
const [personen, masken] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { active: true },
|
||||||
|
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const standardOptionen = Object.fromEntries(
|
||||||
|
OPTION_INFO.map((o) => [o.feld, o.standard]),
|
||||||
|
) as unknown as ProjektOptionen;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-5">
|
||||||
|
<Link href="/projekte" className="inline-flex items-center gap-1.5 text-xs text-db-muted hover:text-slate-200">
|
||||||
|
<ArrowLeft size={13} /> zurück zur Projektliste
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Neues Projekt anlegen</h1>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm leading-relaxed text-db-muted">
|
||||||
|
Aus Bauherrenseite und Fachbereichen ergibt sich, welche der 944 Soll-Unterlagen der
|
||||||
|
EIU-Ablagestruktur für dieses Projekt einschlägig sind. Aus den Terminen ergeben sich
|
||||||
|
deren Fristen. Beides lässt sich später ändern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjektFormular
|
||||||
|
personen={personen.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.name,
|
||||||
|
position: p.position,
|
||||||
|
firma: p.company?.shortName ?? p.company?.name ?? null,
|
||||||
|
extern: p.company?.type === "AUFTRAGNEHMER" || p.company?.type === "SICHERUNGSUNTERNEHMEN",
|
||||||
|
}))}
|
||||||
|
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({ wert: r, label: ROLE_LABEL[r] }))}
|
||||||
|
fachbereicheNetz={FACHBEREICHE_NETZ}
|
||||||
|
fachbereicheBahnhof={FACHBEREICHE_BAHNHOF}
|
||||||
|
masken={masken}
|
||||||
|
optionen={OPTION_INFO.map((o) => ({ ...o }))}
|
||||||
|
standardOptionen={standardOptionen}
|
||||||
|
eigeneId={user.id}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { StatusChip, Fortschritt, Leer } from "@/components/ui";
|
||||||
|
import { datum, eur } from "@/lib/format";
|
||||||
|
import { ROLE_LABEL, can } from "@/lib/rbac";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const BAUHERR_LABEL: Record<string, string> = {
|
||||||
|
DB_INFRAGO_FAHRWEG: "DB InfraGO – Fahrweg",
|
||||||
|
DB_INFRAGO_BAHNHOF: "DB InfraGO – Personenbahnhöfe",
|
||||||
|
DB_ENERGIE: "DB Energie",
|
||||||
|
SONSTIGE: "Sonstige",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function Projekte({ searchParams }: { searchParams: Promise<{ fehler?: string }> }) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const { fehler } = await searchParams;
|
||||||
|
|
||||||
|
const projekte = await prisma.project.findMany({
|
||||||
|
where: user.isAdmin ? {} : { members: { some: { userId: user.id } } },
|
||||||
|
include: {
|
||||||
|
members: { where: { userId: user.id }, select: { role: true } },
|
||||||
|
abschnitte: { take: 1 },
|
||||||
|
_count: { select: { documents: true, issues: true, contracts: true } },
|
||||||
|
},
|
||||||
|
orderBy: [{ archiviert: "asc" }, { nummer: "desc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const slotStat = await prisma.projectDocumentSlot.groupBy({
|
||||||
|
by: ["projectId", "status"],
|
||||||
|
where: { projectId: { in: projekte.map((p) => p.id) } },
|
||||||
|
_count: true,
|
||||||
|
});
|
||||||
|
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist, darf anlegen.
|
||||||
|
const eigeneRollen: Role[] = [
|
||||||
|
...new Set(projekte.flatMap((p) => p.members.map((m) => m.role))),
|
||||||
|
...(user.isAdmin ? (["ADMIN"] as Role[]) : []),
|
||||||
|
];
|
||||||
|
const darfAnlegen = can(eigeneRollen, "projekt.anlegen");
|
||||||
|
|
||||||
|
const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>();
|
||||||
|
for (const s of slotStat) {
|
||||||
|
const e = grad.get(s.projectId) ?? { gesamt: 0, fertig: 0, offen: 0 };
|
||||||
|
e.gesamt += s._count;
|
||||||
|
if (s.status === "FREIGEGEBEN" || s.status === "ENTFAELLT") e.fertig += s._count;
|
||||||
|
else e.offen += s._count;
|
||||||
|
grad.set(s.projectId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Projekte</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-db-muted">{projekte.length} Baumaßnahmen</p>
|
||||||
|
</div>
|
||||||
|
{darfAnlegen && (
|
||||||
|
<Link href="/projekte/neu" className="btn-primary">
|
||||||
|
<Plus size={15} /> Projekt anlegen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler === "kein-zugriff" && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">
|
||||||
|
Für dieses Projekt liegt keine Berechtigung vor.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projekte.length === 0 ? (
|
||||||
|
<Leer text="Keine Projekte vorhanden." />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{projekte.map((p) => {
|
||||||
|
const g = grad.get(p.id) ?? { gesamt: 0, fertig: 0, offen: 0 };
|
||||||
|
const proz = g.gesamt ? Math.round((g.fertig / g.gesamt) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Link key={p.id} href={`/projekte/${p.id}`} className="card block p-4 transition hover:border-slate-500">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[11px] font-mono text-db-muted">{p.nummer}</div>
|
||||||
|
<h2 className="mt-0.5 text-sm font-semibold leading-snug text-white">{p.name}</h2>
|
||||||
|
</div>
|
||||||
|
<StatusChip status={p.status} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-db-muted">
|
||||||
|
{BAUHERR_LABEL[p.bauherr]}
|
||||||
|
{p.abschnitte[0]?.streckenNr && ` · Strecke ${p.abschnitte[0].streckenNr}`}
|
||||||
|
{p.abschnitte[0]?.kmVon && ` · km ${p.abschnitte[0].kmVon}–${p.abschnitte[0].kmBis}`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<Fortschritt wert={proz} ton={proz > 66 ? "gut" : proz > 33 ? "warnung" : "kritisch"} />
|
||||||
|
<span className="w-11 shrink-0 text-right text-[11px] tabular-nums text-db-muted">{proz} %</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-db-muted">
|
||||||
|
Dokumentation {g.fertig}/{g.gesamt} · {g.offen} offen
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mt-3 grid grid-cols-3 gap-2 border-t border-db-line pt-3 text-center">
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] uppercase tracking-wide text-db-muted">Verträge</dt>
|
||||||
|
<dd className="text-sm tabular-nums text-slate-200">{p._count.contracts}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] uppercase tracking-wide text-db-muted">Vorgänge</dt>
|
||||||
|
<dd className="text-sm tabular-nums text-slate-200">{p._count.issues}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="text-[10px] uppercase tracking-wide text-db-muted">Budget</dt>
|
||||||
|
<dd className="text-sm tabular-nums text-slate-200">
|
||||||
|
{p.budgetPlan ? eur(p.budgetPlan).replace(/,\d\d\s/, " ") : "—"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between text-[11px] text-db-muted">
|
||||||
|
<span>{p.members.map((m) => ROLE_LABEL[m.role]).join(", ") || "Administration"}</span>
|
||||||
|
<span>IBN {datum(p.inbetriebnahme)}</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, CalendarClock, FileWarning, MessageSquare, ReceiptText, Wrench } from "lucide-react";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui";
|
||||||
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
|
import { ROLE_LABEL, isExternalOnly } from "@/lib/rbac";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Uebersicht() {
|
||||||
|
const user = await requireUser();
|
||||||
|
|
||||||
|
const mitgliedschaften = await prisma.projectMember.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: { project: { select: { id: true, nummer: true, name: true, kurzname: true, status: true } } },
|
||||||
|
});
|
||||||
|
const projektIds = [...new Set(mitgliedschaften.map((m) => m.projectId))];
|
||||||
|
|
||||||
|
if (projektIds.length === 0) {
|
||||||
|
return <Leer text="Ihnen ist derzeit kein Projekt zugeordnet. Bitte wenden Sie sich an die Projektleitung." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projekte trennen: in welchen ist der Nutzer auf der Bauherrenseite,
|
||||||
|
// in welchen Auftragnehmer? Für Letztere gilt überall die Firmenschranke.
|
||||||
|
const rollenJeProjekt = new Map<string, Role[]>();
|
||||||
|
for (const m of mitgliedschaften) {
|
||||||
|
rollenJeProjekt.set(m.projectId, [...(rollenJeProjekt.get(m.projectId) ?? []), m.role]);
|
||||||
|
}
|
||||||
|
const intern = projektIds.filter(
|
||||||
|
(pid) => user.isAdmin || !isExternalOnly(rollenJeProjekt.get(pid) ?? []),
|
||||||
|
);
|
||||||
|
const extern = projektIds.filter((pid) => !intern.includes(pid));
|
||||||
|
const eigeneFirma = user.companyId ?? "__keine__";
|
||||||
|
|
||||||
|
/** Rechnungen: fremde Projekte vollständig, eigene Projekte nur die eigene Firma. */
|
||||||
|
const rechnungsFilter = {
|
||||||
|
OR: [
|
||||||
|
...(intern.length ? [{ projectId: { in: intern } }] : []),
|
||||||
|
...(extern.length ? [{ projectId: { in: extern }, companyId: eigeneFirma }] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const [aufgaben, vorgaenge, rechnungen, fristen, ungeleseneKanaele, slotStat, zettel] = await Promise.all([
|
||||||
|
prisma.task.findMany({
|
||||||
|
where: { assigneeId: user.id, status: { in: ["OFFEN", "IN_ARBEIT"] } },
|
||||||
|
include: { project: { select: { id: true, kurzname: true, nummer: true } } },
|
||||||
|
orderBy: [{ faelligAm: "asc" }],
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
prisma.issue.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: projektIds },
|
||||||
|
status: { notIn: ["ERLEDIGT", "ABGELEHNT"] },
|
||||||
|
OR: [{ assigneeId: user.id }, { schwere: { in: ["HOCH", "SICHERHEITSRELEVANT"] } }],
|
||||||
|
},
|
||||||
|
include: { project: { select: { id: true, kurzname: true } } },
|
||||||
|
orderBy: [{ fristAm: "asc" }],
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
prisma.invoice.findMany({
|
||||||
|
where: {
|
||||||
|
...rechnungsFilter,
|
||||||
|
status: { in: ["EINGEGANGEN", "IN_PRUEFUNG", "SACHLICH_RICHTIG", "RUECKFRAGE"] },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: { select: { id: true, kurzname: true } },
|
||||||
|
company: { select: { name: true } },
|
||||||
|
_count: { select: { befunde: true } },
|
||||||
|
},
|
||||||
|
orderBy: { eingangsdatum: "asc" },
|
||||||
|
take: 6,
|
||||||
|
}),
|
||||||
|
prisma.projectDocumentSlot.findMany({
|
||||||
|
where: {
|
||||||
|
projectId: { in: intern },
|
||||||
|
status: { notIn: ["FREIGEGEBEN", "ENTFAELLT"] },
|
||||||
|
frist: { not: null, lte: new Date(Date.now() + 45 * 864e5) },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
requirement: { select: { titel: true, ril: true } },
|
||||||
|
project: { select: { id: true, kurzname: true } },
|
||||||
|
},
|
||||||
|
orderBy: { frist: "asc" },
|
||||||
|
take: 8,
|
||||||
|
}),
|
||||||
|
prisma.channelMember.findMany({
|
||||||
|
where: { userId: user.id, stumm: false },
|
||||||
|
include: {
|
||||||
|
channel: {
|
||||||
|
select: {
|
||||||
|
id: true, name: true, projectId: true,
|
||||||
|
project: { select: { kurzname: true } },
|
||||||
|
nachrichten: { orderBy: { createdAt: "desc" }, take: 1, select: { createdAt: true, text: true, author: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
prisma.projectDocumentSlot.groupBy({
|
||||||
|
by: ["projectId", "status"],
|
||||||
|
where: { projectId: { in: intern } },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.timeSheet.count({ where: { projectId: { in: intern }, status: "EINGEREICHT" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const kritischeBefunde = await prisma.invoiceFinding.count({
|
||||||
|
where: { invoice: { projectId: { in: intern } }, schwere: "KRITISCH", erledigt: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const ueberfaellig = fristen.filter((f) => (tageBis(f.frist) ?? 99) < 0).length;
|
||||||
|
|
||||||
|
const neueNachrichten = ungeleseneKanaele.filter(
|
||||||
|
(m) => m.channel.nachrichten[0] && (!m.gelesenBis || m.channel.nachrichten[0].createdAt > m.gelesenBis),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Dokumentationsgrad je Projekt
|
||||||
|
const proProjekt = new Map<string, { gesamt: number; fertig: number }>();
|
||||||
|
for (const s of slotStat) {
|
||||||
|
const e = proProjekt.get(s.projectId) ?? { gesamt: 0, fertig: 0 };
|
||||||
|
e.gesamt += s._count;
|
||||||
|
if (s.status === "FREIGEGEBEN" || s.status === "ENTFAELLT") e.fertig += s._count;
|
||||||
|
proProjekt.set(s.projectId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollen = [...new Set(mitgliedschaften.map((m) => m.role))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">Guten Tag, {user.name.split(" ")[0]}.</h1>
|
||||||
|
<p className="mt-0.5 text-sm text-db-muted">
|
||||||
|
{rollen.map((r) => ROLE_LABEL[r]).join(" · ")} · {projektIds.length}{" "}
|
||||||
|
{projektIds.length === 1 ? "Projekt" : "Projekte"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel
|
||||||
|
titel="Überfällige Unterlagen"
|
||||||
|
wert={ueberfaellig}
|
||||||
|
hinweis="Soll-Dokumente mit abgelaufener Frist"
|
||||||
|
ton={ueberfaellig > 0 ? "kritisch" : "gut"}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
titel="Kritische Prüfbefunde"
|
||||||
|
wert={kritischeBefunde}
|
||||||
|
hinweis="offene Befunde der Rechnungsprüfung"
|
||||||
|
ton={kritischeBefunde > 0 ? "kritisch" : "gut"}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
titel="Stundenzettel zu prüfen"
|
||||||
|
wert={zettel}
|
||||||
|
hinweis="von Auftragnehmern eingereicht"
|
||||||
|
ton={zettel > 0 ? "warnung" : "gut"}
|
||||||
|
/>
|
||||||
|
<Kachel
|
||||||
|
titel="Meine offenen Aufgaben"
|
||||||
|
wert={aufgaben.length}
|
||||||
|
hinweis={aufgaben[0]?.faelligAm ? `nächste am ${datum(aufgaben[0].faelligAm)}` : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
{/* ── Projekte ─────────────────────────────────────────── */}
|
||||||
|
<section className="card lg:col-span-1">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Meine Projekte</h2>
|
||||||
|
<div className="divide-y divide-db-line/70">
|
||||||
|
{mitgliedschaften
|
||||||
|
.filter((m, i, a) => a.findIndex((x) => x.projectId === m.projectId) === i)
|
||||||
|
.map((m) => {
|
||||||
|
const st = proProjekt.get(m.projectId) ?? { gesamt: 0, fertig: 0 };
|
||||||
|
const grad = st.gesamt ? Math.round((st.fertig / st.gesamt) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<Link key={m.projectId} href={`/projekte/${m.projectId}`} className="block px-4 py-3 hover:bg-db-line/25">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-medium text-slate-100">
|
||||||
|
{m.project.kurzname ?? m.project.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-db-muted">{m.project.nummer}</div>
|
||||||
|
</div>
|
||||||
|
<StatusChip status={m.project.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2.5 flex items-center gap-2">
|
||||||
|
<Fortschritt wert={grad} ton={grad > 66 ? "gut" : grad > 33 ? "warnung" : "kritisch"} />
|
||||||
|
<span className="w-12 shrink-0 text-right text-[11px] tabular-nums text-db-muted">{grad} %</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[11px] text-db-muted">
|
||||||
|
Dokumentation: {st.fertig} von {st.gesamt} Soll-Unterlagen erledigt
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Fristen ──────────────────────────────────────────── */}
|
||||||
|
<section className="card lg:col-span-2">
|
||||||
|
<h2 className="flex items-center gap-2 border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<CalendarClock size={15} className="text-db-muted" /> Fällige Unterlagen aus der Ablagestruktur
|
||||||
|
</h2>
|
||||||
|
{fristen.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-db-muted">Keine Unterlage wird in den nächsten 45 Tagen fällig.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{fristen.map((f) => {
|
||||||
|
const tage = tageBis(f.frist);
|
||||||
|
return (
|
||||||
|
<tr key={f.id} className="row">
|
||||||
|
<td className="td w-20 whitespace-nowrap tabular-nums">
|
||||||
|
<span className={tage !== null && tage < 0 ? "text-red-400" : tage !== null && tage < 14 ? "text-amber-400" : "text-slate-300"}>
|
||||||
|
{datum(f.frist)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<Link href={`/projekte/${f.projectId}/dokumentation`} className="text-slate-200 hover:text-db-cyan">
|
||||||
|
{f.requirement.titel}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-db-muted">
|
||||||
|
{f.project.kurzname}
|
||||||
|
{f.requirement.ril && ` · Ril ${f.requirement.ril}`}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="td w-28 text-right">
|
||||||
|
<StatusChip status={f.status} />
|
||||||
|
</td>
|
||||||
|
<td className="td w-24 whitespace-nowrap text-right text-xs tabular-nums text-db-muted">
|
||||||
|
{tage === null ? "—" : tage < 0 ? `${-tage} Tg. über` : `in ${tage} Tg.`}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
{/* ── Rechnungen ───────────────────────────────────────── */}
|
||||||
|
<section className="card lg:col-span-2">
|
||||||
|
<h2 className="flex items-center gap-2 border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<ReceiptText size={15} className="text-db-muted" /> Rechnungen in Prüfung
|
||||||
|
</h2>
|
||||||
|
{rechnungen.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-db-muted">Derzeit liegt keine Rechnung zur Prüfung vor.</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="th">Rechnung</th>
|
||||||
|
<th className="th">Auftragnehmer</th>
|
||||||
|
<th className="th text-right">Netto</th>
|
||||||
|
<th className="th text-right">Befunde</th>
|
||||||
|
<th className="th text-right">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rechnungen.map((r) => (
|
||||||
|
<tr key={r.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<Link href={`/projekte/${r.projectId}/rechnungen/${r.id}`} className="link font-medium">
|
||||||
|
{r.rechnungsNr}
|
||||||
|
</Link>
|
||||||
|
<div className="text-[11px] text-db-muted">Eingang {datum(r.eingangsdatum)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-slate-300">{r.company.name}</td>
|
||||||
|
<td className="td text-right tabular-nums">{eur(r.nettoBetrag)}</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
{r._count.befunde > 0 ? (
|
||||||
|
<span className="chip border-red-800 bg-red-900/25 text-red-300">
|
||||||
|
<AlertTriangle size={11} /> {r._count.befunde}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-db-muted">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right">
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Aufgaben ─────────────────────────────────────────── */}
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="flex items-center gap-2 border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<Wrench size={15} className="text-db-muted" /> Meine Aufgaben
|
||||||
|
</h2>
|
||||||
|
{aufgaben.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-db-muted">Keine offenen Aufgaben.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-db-line/70">
|
||||||
|
{aufgaben.map((a) => {
|
||||||
|
const tage = tageBis(a.faelligAm);
|
||||||
|
return (
|
||||||
|
<li key={a.id} className="px-4 py-2.5">
|
||||||
|
<Link href={`/projekte/${a.projectId}`} className="text-sm text-slate-200 hover:text-db-cyan">
|
||||||
|
{a.titel}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-0.5 text-[11px] text-db-muted">
|
||||||
|
{a.project.kurzname} ·{" "}
|
||||||
|
<span className={tage !== null && tage < 0 ? "text-red-400" : tage !== null && tage <= 2 ? "text-amber-400" : ""}>
|
||||||
|
{a.faelligAm ? `fällig ${datum(a.faelligAm)}` : "ohne Termin"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
{/* ── Vorgänge ─────────────────────────────────────────── */}
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="flex items-center gap-2 border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<FileWarning size={15} className="text-db-muted" /> Offene Meldungen und Mängel
|
||||||
|
</h2>
|
||||||
|
{vorgaenge.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-db-muted">Keine offenen Vorgänge.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-db-line/70">
|
||||||
|
{vorgaenge.map((v) => (
|
||||||
|
<li key={v.id} className="px-4 py-2.5">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<Link href={`/projekte/${v.projectId}/meldungen`} className="text-sm text-slate-200 hover:text-db-cyan">
|
||||||
|
{v.titel}
|
||||||
|
</Link>
|
||||||
|
<StatusChip status={v.schwere} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-db-muted">
|
||||||
|
{v.project.kurzname} · {v.typ.replace(/_/g, " ")} ·{" "}
|
||||||
|
{v.fristAm ? `Frist ${datum(v.fristAm)}` : "ohne Frist"}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── Nachrichten ──────────────────────────────────────── */}
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="flex items-center gap-2 border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
<MessageSquare size={15} className="text-db-muted" /> Neues aus der Kommunikation
|
||||||
|
</h2>
|
||||||
|
{neueNachrichten.length === 0 ? (
|
||||||
|
<p className="px-4 py-6 text-center text-sm text-db-muted">Alles gelesen.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-db-line/70">
|
||||||
|
{neueNachrichten.slice(0, 6).map((m) => (
|
||||||
|
<li key={m.id} className="px-4 py-2.5">
|
||||||
|
<Link
|
||||||
|
href={`/projekte/${m.channel.projectId}/kommunikation?kanal=${m.channel.id}`}
|
||||||
|
className="text-sm font-medium text-slate-200 hover:text-db-cyan"
|
||||||
|
>
|
||||||
|
#{m.channel.name}
|
||||||
|
</Link>
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-xs text-db-muted">
|
||||||
|
<span className="text-slate-400">{m.channel.nachrichten[0].author.name}:</span>{" "}
|
||||||
|
{m.channel.nachrichten[0].text}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { Kachel, StatusChip } from "@/components/ui";
|
||||||
|
import { ROLE_LABEL } from "@/lib/rbac";
|
||||||
|
import { datumZeit } from "@/lib/format";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function Verwaltung() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user.isAdmin) redirect("/uebersicht");
|
||||||
|
|
||||||
|
const [benutzer, firmen, protokoll, stats] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
include: { company: { select: { name: true } }, memberships: { select: { role: true } } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
}),
|
||||||
|
prisma.company.findMany({ include: { _count: { select: { users: true, contracts: true } } }, orderBy: { name: "asc" } }),
|
||||||
|
prisma.auditLog.findMany({
|
||||||
|
include: { user: { select: { name: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 60,
|
||||||
|
}),
|
||||||
|
Promise.all([
|
||||||
|
prisma.project.count(),
|
||||||
|
prisma.document.count(),
|
||||||
|
prisma.documentRequirement.count(),
|
||||||
|
prisma.filingNode.count(),
|
||||||
|
prisma.documentVersion.aggregate({ _sum: { groesse: true } }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [anzProjekte, anzDokumente, anzSoll, anzOrdner, speicher] = stats;
|
||||||
|
const mb = Number(speicher._sum.groesse ?? 0) / 1048576;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<h1 className="text-xl font-semibold text-white">Verwaltung</h1>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Kachel titel="Projekte" wert={anzProjekte} />
|
||||||
|
<Kachel titel="Benutzer" wert={benutzer.length} hinweis={`${firmen.length} Firmen`} />
|
||||||
|
<Kachel titel="Ablagestruktur" wert={anzSoll} hinweis={`${anzOrdner} Ordner, Katalog aus der EIU-Vorgabe`} />
|
||||||
|
<Kachel titel="Dokumente" wert={anzDokumente} hinweis={`${mb.toFixed(1)} MB im Dateispeicher`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Benutzer</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Name</th>
|
||||||
|
<th className="th">E-Mail</th>
|
||||||
|
<th className="th">Firma</th>
|
||||||
|
<th className="th">Rollen</th>
|
||||||
|
<th className="th w-40 text-right">letzte Anmeldung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{benutzer.map((b) => (
|
||||||
|
<tr key={b.id} className="row">
|
||||||
|
<td className="td">
|
||||||
|
<span className="text-slate-100">{b.name}</span>
|
||||||
|
{b.isAdmin && <span className="ml-2 chip border-db-red/50 bg-db-red/15 text-red-300">Admin</span>}
|
||||||
|
{!b.active && <span className="ml-2 chip border-slate-700 bg-slate-800 text-slate-500">gesperrt</span>}
|
||||||
|
<div className="text-[11px] text-db-muted">{b.position}</div>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-400">{b.email}</td>
|
||||||
|
<td className="td text-xs text-slate-400">{b.company?.name ?? "—"}</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
{[...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))].join(", ") || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right text-xs tabular-nums text-db-muted">{datumZeit(b.lastLoginAt)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Firmen</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Firma</th>
|
||||||
|
<th className="th">Art</th>
|
||||||
|
<th className="th">Ort</th>
|
||||||
|
<th className="th">Kreditor / USt-IdNr.</th>
|
||||||
|
<th className="th text-right">Nutzer</th>
|
||||||
|
<th className="th text-right">Verträge</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{firmen.map((f) => (
|
||||||
|
<tr key={f.id} className="row">
|
||||||
|
<td className="td text-slate-100">{f.name}</td>
|
||||||
|
<td className="td text-xs text-slate-400">{f.type.replace(/_/g, " ").toLowerCase()}</td>
|
||||||
|
<td className="td text-xs text-slate-400">{f.city ?? "—"}</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
{f.kreditorNr ?? "—"}
|
||||||
|
{f.ustId && <div>{f.ustId}</div>}
|
||||||
|
</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{f._count.users}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{f._count.contracts}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
||||||
|
Protokoll <span className="font-normal text-db-muted">– revisionssichere Nachvollziehbarkeit</span>
|
||||||
|
</h2>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th w-40">Zeitpunkt</th>
|
||||||
|
<th className="th w-40">Person</th>
|
||||||
|
<th className="th w-44">Aktion</th>
|
||||||
|
<th className="th">Objekt</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{protokoll.map((p) => (
|
||||||
|
<tr key={p.id} className="row">
|
||||||
|
<td className="td whitespace-nowrap text-xs tabular-nums text-db-muted">{datumZeit(p.createdAt)}</td>
|
||||||
|
<td className="td text-xs text-slate-300">{p.user?.name ?? "System"}</td>
|
||||||
|
<td className="td">
|
||||||
|
<span className="chip border-db-line bg-db-dark text-slate-300">{p.aktion}</span>
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-slate-400">
|
||||||
|
{p.entitaet}
|
||||||
|
{p.entitaetId && <span className="font-mono text-db-muted"> · {p.entitaetId.slice(0, 10)}…</span>}
|
||||||
|
{p.details ? (
|
||||||
|
<div className="font-mono text-[10px] text-db-muted">{JSON.stringify(p.details).slice(0, 140)}</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser, login } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function Anmelden({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ fehler?: string }>;
|
||||||
|
}) {
|
||||||
|
if (await getSessionUser()) redirect("/uebersicht");
|
||||||
|
const { fehler } = await searchParams;
|
||||||
|
|
||||||
|
async function anmelden(formData: FormData) {
|
||||||
|
"use server";
|
||||||
|
const email = String(formData.get("email") ?? "");
|
||||||
|
const passwort = String(formData.get("passwort") ?? "");
|
||||||
|
const res = await login(email, passwort);
|
||||||
|
if (!res.ok) redirect("/anmelden?fehler=1");
|
||||||
|
redirect("/uebersicht");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-db-dark px-4">
|
||||||
|
<div className="w-full max-w-[400px]">
|
||||||
|
<div className="mb-7 flex items-center gap-3">
|
||||||
|
<div className="flex h-11 w-11 items-center justify-center rounded-md bg-db-red text-lg font-bold text-white">
|
||||||
|
BD
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold text-white">BauDoc Bahn</h1>
|
||||||
|
<p className="text-xs text-db-muted">Baustellen- und Dokumentenmanagement</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={anmelden} className="card-pad space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block" htmlFor="email">
|
||||||
|
E-Mail
|
||||||
|
</label>
|
||||||
|
<input id="email" name="email" type="email" required autoFocus className="input" autoComplete="username" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block" htmlFor="passwort">
|
||||||
|
Kennwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="passwort"
|
||||||
|
name="passwort"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">
|
||||||
|
E-Mail oder Kennwort ist nicht korrekt.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" className="btn-primary w-full justify-center">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-5 rounded-md border border-db-line/60 bg-db-slate/40 p-3 text-xs leading-relaxed text-db-muted">
|
||||||
|
<p className="mb-1.5 font-semibold text-slate-300">Demo-Zugänge (Kennwort: baudoc2026)</p>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
<li>
|
||||||
|
<code className="text-slate-300">m.kruse@infrago.local</code> · Projektleitung
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-slate-300">j.hoffmann@infrago.local</code> · Bauüberwacher Bahn
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-slate-300">r.pauls@infrago.local</code> · Kaufmännische Steuerung
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<code className="text-slate-300">bauleitung@nordbahn.local</code> · Auftragnehmer
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getSessionUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { isExternalOnly } from "@/lib/rbac";
|
||||||
|
import { leseDatei } from "@/lib/storage";
|
||||||
|
import { protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ versionId: string }> }) {
|
||||||
|
const { versionId } = await params;
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 });
|
||||||
|
|
||||||
|
const version = await prisma.documentVersion.findUnique({
|
||||||
|
where: { id: versionId },
|
||||||
|
include: { document: { select: { projectId: true, vertraulich: true, titel: true } } },
|
||||||
|
});
|
||||||
|
if (!version) return new NextResponse("Nicht gefunden.", { status: 404 });
|
||||||
|
|
||||||
|
const roles = await rolesInProject(user.id, version.document.projectId);
|
||||||
|
if (roles.length === 0) return new NextResponse("Kein Zugriff.", { status: 403 });
|
||||||
|
if (version.document.vertraulich !== "OFFEN" && isExternalOnly(roles))
|
||||||
|
return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 });
|
||||||
|
|
||||||
|
let daten: Buffer;
|
||||||
|
try {
|
||||||
|
daten = await leseDatei(version.pfad);
|
||||||
|
} catch {
|
||||||
|
return new NextResponse("Die Datei ist im Speicher nicht mehr vorhanden.", { status: 410 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokolliere({
|
||||||
|
userId: user.id,
|
||||||
|
aktion: "DOWNLOAD",
|
||||||
|
entitaet: "DocumentVersion",
|
||||||
|
entitaetId: version.id,
|
||||||
|
projectId: version.document.projectId,
|
||||||
|
details: { dateiname: version.dateiname },
|
||||||
|
});
|
||||||
|
|
||||||
|
return new NextResponse(new Uint8Array(daten), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": version.mimeType,
|
||||||
|
"Content-Length": String(daten.length),
|
||||||
|
"Content-Disposition": `inline; filename*=UTF-8''${encodeURIComponent(version.dateiname)}`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getSessionUser, rolesInProject } from "@/lib/auth";
|
||||||
|
import { UEBERGABE_LABEL } from "@/lib/fristen";
|
||||||
|
|
||||||
|
/** Export des Soll-Ist-Standes – bewusst im Format der Ursprungs-Excel. */
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 });
|
||||||
|
if ((await rolesInProject(user.id, id)).length === 0)
|
||||||
|
return new NextResponse("Kein Zugriff.", { status: 403 });
|
||||||
|
|
||||||
|
const akte = new URL(req.url).searchParams.get("akte");
|
||||||
|
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id } });
|
||||||
|
const slots = await prisma.projectDocumentSlot.findMany({
|
||||||
|
where: { projectId: id, ...(akte ? { requirement: { node: { akte: akte as never } } } : {}) },
|
||||||
|
include: { requirement: { include: { node: true } }, responsible: { select: { name: true } } },
|
||||||
|
orderBy: { requirement: { ordnung: "asc" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const d = (x: Date | null) => (x ? x.toLocaleDateString("de-DE") : "");
|
||||||
|
const q = (s: unknown) => `"${String(s ?? "").replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
const zeilen = [
|
||||||
|
[
|
||||||
|
"Gliederung", "Ordner", "Unterlage", "Richtlinie/VV/Gesetz",
|
||||||
|
"vorhanden (x/-)", "Status", "Verantwortlich", "Datum Eintreffen",
|
||||||
|
"Übergabezeitpunkt", "Frist", "Aufbewahrung", "Medium", "Fachbereiche", "Bemerkung",
|
||||||
|
].map(q).join(";"),
|
||||||
|
...slots.map((s) =>
|
||||||
|
[
|
||||||
|
s.requirement.node.code,
|
||||||
|
s.requirement.node.titel,
|
||||||
|
s.requirement.titel,
|
||||||
|
s.requirement.ril ?? "",
|
||||||
|
s.status === "FREIGEGEBEN" ? "x" : s.status === "ENTFAELLT" ? "entfällt" : "-",
|
||||||
|
s.status,
|
||||||
|
s.responsible?.name ?? "",
|
||||||
|
d(s.eingangAm),
|
||||||
|
s.requirement.uebergabe.map((u) => UEBERGABE_LABEL[u]).join(", "),
|
||||||
|
d(s.frist),
|
||||||
|
s.requirement.aufbewahrungNutzungsdauer ? "Nutzungsdauer" : (s.requirement.aufbewahrungJahre ?? ""),
|
||||||
|
s.requirement.medien.join("/"),
|
||||||
|
s.requirement.fachbereiche.join(", "),
|
||||||
|
s.bemerkung ?? "",
|
||||||
|
].map(q).join(";"),
|
||||||
|
),
|
||||||
|
].join("\r\n");
|
||||||
|
|
||||||
|
return new NextResponse("" + zeilen, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="Dokumentationsstand_${projekt.nummer}${akte ? "_" + akte : ""}.csv"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-db-dark text-slate-200 antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
@apply rounded-lg border border-db-line bg-db-slate/60;
|
||||||
|
}
|
||||||
|
.card-pad {
|
||||||
|
@apply rounded-lg border border-db-line bg-db-slate/60 p-4;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
@apply text-[11px] font-semibold uppercase tracking-wider text-db-muted;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
@apply w-full rounded-md border border-db-line bg-db-dark px-3 py-2 text-sm text-slate-100
|
||||||
|
placeholder:text-slate-600 focus:border-db-cyan focus:outline-none focus:ring-1 focus:ring-db-cyan;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition
|
||||||
|
disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-db-red text-white hover:bg-red-600;
|
||||||
|
}
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn border border-db-line bg-db-slate text-slate-200 hover:border-slate-500 hover:bg-db-line;
|
||||||
|
}
|
||||||
|
.btn-sm {
|
||||||
|
@apply px-2.5 py-1 text-xs;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
@apply inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium;
|
||||||
|
}
|
||||||
|
.th {
|
||||||
|
@apply px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-wider text-db-muted;
|
||||||
|
}
|
||||||
|
.td {
|
||||||
|
@apply px-3 py-2 align-top text-sm;
|
||||||
|
}
|
||||||
|
.row {
|
||||||
|
@apply border-t border-db-line/70 hover:bg-db-line/25;
|
||||||
|
}
|
||||||
|
.link {
|
||||||
|
@apply text-db-cyan hover:underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: #131821; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #2B3648; border-radius: 5px; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #3c4a61; }
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "BauDoc Bahn",
|
||||||
|
description: "Baustellen- und Dokumentenmanagement für Eisenbahninfrastruktur",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body>{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getSessionUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export default async function Root() {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
redirect(user ? "/uebersicht" : "/anmelden");
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
export function NavLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
exact = false,
|
||||||
|
className = "",
|
||||||
|
activeClassName = "",
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
exact?: boolean;
|
||||||
|
className?: string;
|
||||||
|
activeClassName?: string;
|
||||||
|
}) {
|
||||||
|
const pfad = usePathname();
|
||||||
|
const aktiv = exact ? pfad === href : pfad === href || pfad.startsWith(href + "/");
|
||||||
|
return (
|
||||||
|
<Link href={href} className={`${className} ${aktiv ? activeClassName : ""}`}>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabLink({ href, children, exact = false }: { href: string; children: React.ReactNode; exact?: boolean }) {
|
||||||
|
const pfad = usePathname();
|
||||||
|
const aktiv = exact ? pfad === href : pfad === href || pfad.startsWith(href + "/");
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className={`whitespace-nowrap border-b-2 px-3 py-2.5 text-sm transition ${
|
||||||
|
aktiv
|
||||||
|
? "border-db-red font-medium text-white"
|
||||||
|
: "border-transparent text-db-muted hover:border-db-line hover:text-slate-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Ampel } from "@/lib/fristen";
|
||||||
|
|
||||||
|
export function Kachel({
|
||||||
|
titel,
|
||||||
|
wert,
|
||||||
|
hinweis,
|
||||||
|
ton = "neutral",
|
||||||
|
}: {
|
||||||
|
titel: string;
|
||||||
|
wert: React.ReactNode;
|
||||||
|
hinweis?: React.ReactNode;
|
||||||
|
ton?: "neutral" | "gut" | "warnung" | "kritisch";
|
||||||
|
}) {
|
||||||
|
const farbe = {
|
||||||
|
neutral: "text-slate-100",
|
||||||
|
gut: "text-emerald-400",
|
||||||
|
warnung: "text-amber-400",
|
||||||
|
kritisch: "text-red-400",
|
||||||
|
}[ton];
|
||||||
|
return (
|
||||||
|
<div className="card-pad">
|
||||||
|
<div className="label">{titel}</div>
|
||||||
|
<div className={`mt-1.5 text-2xl font-semibold tabular-nums ${farbe}`}>{wert}</div>
|
||||||
|
{hinweis && <div className="mt-1 text-xs text-db-muted">{hinweis}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_FARBEN: Record<string, string> = {
|
||||||
|
// Slots / Dokumente
|
||||||
|
OFFEN: "border-slate-600 bg-slate-700/30 text-slate-300",
|
||||||
|
ANGEFORDERT: "border-sky-700 bg-sky-900/30 text-sky-300",
|
||||||
|
IN_ARBEIT: "border-sky-700 bg-sky-900/30 text-sky-300",
|
||||||
|
ENTWURF: "border-slate-600 bg-slate-700/30 text-slate-300",
|
||||||
|
EINGEREICHT: "border-indigo-700 bg-indigo-900/30 text-indigo-300",
|
||||||
|
EINGEGANGEN: "border-indigo-700 bg-indigo-900/30 text-indigo-300",
|
||||||
|
IN_PRUEFUNG: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
RUECKFRAGE: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
FREIGEGEBEN: "border-emerald-700 bg-emerald-900/25 text-emerald-300",
|
||||||
|
ANERKANNT: "border-emerald-700 bg-emerald-900/25 text-emerald-300",
|
||||||
|
TEILWEISE_ANERKANNT: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
ABGELEHNT: "border-red-800 bg-red-900/25 text-red-300",
|
||||||
|
ZURUECKGEWIESEN: "border-red-800 bg-red-900/25 text-red-300",
|
||||||
|
ENTFAELLT: "border-slate-700 bg-slate-800/40 text-slate-500",
|
||||||
|
SACHLICH_RICHTIG: "border-teal-700 bg-teal-900/25 text-teal-300",
|
||||||
|
RECHNERISCH_RICHTIG: "border-cyan-700 bg-cyan-900/25 text-cyan-300",
|
||||||
|
BEZAHLT: "border-emerald-700 bg-emerald-900/25 text-emerald-300",
|
||||||
|
// Vorgänge
|
||||||
|
IN_BEARBEITUNG: "border-sky-700 bg-sky-900/30 text-sky-300",
|
||||||
|
BEHOBEN_GEMELDET: "border-teal-700 bg-teal-900/25 text-teal-300",
|
||||||
|
NACHKONTROLLE: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
ERLEDIGT: "border-emerald-700 bg-emerald-900/25 text-emerald-300",
|
||||||
|
// Schwere
|
||||||
|
GERING: "border-slate-600 bg-slate-700/30 text-slate-300",
|
||||||
|
MITTEL: "border-sky-700 bg-sky-900/30 text-sky-300",
|
||||||
|
HOCH: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
SICHERHEITSRELEVANT: "border-red-800 bg-red-900/30 text-red-300",
|
||||||
|
KRITISCH: "border-red-800 bg-red-900/30 text-red-300",
|
||||||
|
WARNUNG: "border-amber-700 bg-amber-900/25 text-amber-300",
|
||||||
|
INFO: "border-slate-600 bg-slate-700/30 text-slate-300",
|
||||||
|
// Projekt
|
||||||
|
BAU: "border-emerald-700 bg-emerald-900/25 text-emerald-300",
|
||||||
|
PLANUNG: "border-sky-700 bg-sky-900/30 text-sky-300",
|
||||||
|
VERGABE: "border-indigo-700 bg-indigo-900/30 text-indigo-300",
|
||||||
|
ABGESCHLOSSEN: "border-slate-700 bg-slate-800/40 text-slate-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StatusChip({ status, text }: { status: string; text?: string }) {
|
||||||
|
const cls = STATUS_FARBEN[status] ?? "border-slate-600 bg-slate-700/30 text-slate-300";
|
||||||
|
return <span className={`chip ${cls}`}>{text ?? lesbar(status)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lesbar(v: string) {
|
||||||
|
return v
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AmpelPunkt({ ampel, titel }: { ampel: Ampel; titel?: string }) {
|
||||||
|
const farbe = { GRUEN: "bg-emerald-500", GELB: "bg-amber-400", ROT: "bg-red-500", GRAU: "bg-slate-600" }[ampel];
|
||||||
|
return <span title={titel} className={`inline-block h-2 w-2 shrink-0 rounded-full ${farbe}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Fortschritt({ wert, ton = "gut" }: { wert: number; ton?: "gut" | "warnung" | "kritisch" }) {
|
||||||
|
const farbe = { gut: "bg-emerald-500", warnung: "bg-amber-400", kritisch: "bg-red-500" }[ton];
|
||||||
|
return (
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-db-line">
|
||||||
|
<div className={`h-full rounded-full ${farbe}`} style={{ width: `${Math.min(100, Math.max(0, wert))}%` }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Leer({ text }: { text: string }) {
|
||||||
|
return <div className="card-pad text-center text-sm text-db-muted">{text}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KeinZugriff({ bereich }: { bereich: string }) {
|
||||||
|
return (
|
||||||
|
<div className="card-pad">
|
||||||
|
<h2 className="text-sm font-semibold text-white">{bereich} ist für Sie nicht freigegeben</h2>
|
||||||
|
<p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-db-muted">
|
||||||
|
Die Projektleitung entscheidet je Projekt, welche Bereiche für Auftragnehmer sichtbar sind.
|
||||||
|
Wenn Sie diesen Bereich benötigen, wenden Sie sich an die Projektleitung – die Einstellung
|
||||||
|
lässt sich jederzeit ändern.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Feld({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="label">{label}</div>
|
||||||
|
<div className="mt-0.5 text-sm text-slate-200">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
const COOKIE = "baudoc_session";
|
||||||
|
const SESSION_DAYS = 12;
|
||||||
|
|
||||||
|
export type SessionUser = {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isAdmin: boolean;
|
||||||
|
companyId: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
companyType: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function hashPassword(pw: string) {
|
||||||
|
return bcrypt.hash(pw, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(pw: string, hash: string) {
|
||||||
|
return bcrypt.compare(pw, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: email.trim().toLowerCase() },
|
||||||
|
include: { company: true },
|
||||||
|
});
|
||||||
|
if (!user || !user.active) return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
||||||
|
if (!(await verifyPassword(password, user.passwordHash)))
|
||||||
|
return { ok: false as const, error: "Anmeldung fehlgeschlagen." };
|
||||||
|
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_DAYS * 864e5);
|
||||||
|
const h = await headers();
|
||||||
|
|
||||||
|
await prisma.session.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
userAgent: h.get("user-agent")?.slice(0, 250) ?? null,
|
||||||
|
ip: h.get("x-forwarded-for")?.split(",")[0]?.trim() ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: { userId: user.id, aktion: "LOGIN", entitaet: "User", entitaetId: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const store = await cookies();
|
||||||
|
store.set(COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
secure: process.env.NODE_ENV === "production" && (process.env.APP_URL ?? "").startsWith("https"),
|
||||||
|
path: "/",
|
||||||
|
expires: expiresAt,
|
||||||
|
});
|
||||||
|
return { ok: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
const store = await cookies();
|
||||||
|
const token = store.get(COOKIE)?.value;
|
||||||
|
if (token) await prisma.session.deleteMany({ where: { token } });
|
||||||
|
store.delete(COOKIE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||||
|
const store = await cookies();
|
||||||
|
const token = store.get(COOKIE)?.value;
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
const session = await prisma.session.findUnique({
|
||||||
|
where: { token },
|
||||||
|
include: { user: { include: { company: true } } },
|
||||||
|
});
|
||||||
|
if (!session || session.expiresAt < new Date() || !session.user.active) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: session.user.id,
|
||||||
|
email: session.user.email,
|
||||||
|
name: session.user.name,
|
||||||
|
isAdmin: session.user.isAdmin,
|
||||||
|
companyId: session.user.companyId,
|
||||||
|
companyName: session.user.company?.name ?? null,
|
||||||
|
companyType: session.user.company?.type ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireUser(): Promise<SessionUser> {
|
||||||
|
const user = await getSessionUser();
|
||||||
|
if (!user) redirect("/anmelden");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rollen des Nutzers im Projekt (Admins gelten überall als ADMIN). */
|
||||||
|
export async function rolesInProject(userId: string, projectId: string): Promise<Role[]> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } });
|
||||||
|
const memberships = await prisma.projectMember.findMany({
|
||||||
|
where: { userId, projectId },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
const roles = memberships.map((m) => m.role);
|
||||||
|
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function requireProjectAccess(projectId: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
||||||
|
return { user, roles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zugriff prüfen und zugleich das Projekt samt seiner Einstellungen laden.
|
||||||
|
* Seiten sollten diese Variante verwenden, damit die projektbezogenen
|
||||||
|
* Sichtbarkeitsschalter nirgends vergessen werden.
|
||||||
|
*/
|
||||||
|
export async function projektKontext(projectId: string) {
|
||||||
|
const user = await requireUser();
|
||||||
|
const roles = await rolesInProject(user.id, projectId);
|
||||||
|
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
||||||
|
|
||||||
|
const projekt = await prisma.project.findUnique({ where: { id: projectId } });
|
||||||
|
if (!projekt) redirect("/projekte?fehler=nicht-gefunden");
|
||||||
|
|
||||||
|
const { optionenAus } = await import("./projekte");
|
||||||
|
const { isExternalOnly } = await import("./rbac");
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
roles,
|
||||||
|
projekt,
|
||||||
|
optionen: optionenAus(projekt),
|
||||||
|
/** true = darf ausschließlich Daten der eigenen Firma sehen */
|
||||||
|
nurEigeneFirma: isExternalOnly(roles),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
export const eur = (v: Prisma.Decimal | number | null | undefined) =>
|
||||||
|
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(Number(v ?? 0));
|
||||||
|
|
||||||
|
export const zahl = (v: Prisma.Decimal | number | null | undefined, frac = 2) =>
|
||||||
|
new Intl.NumberFormat("de-DE", { minimumFractionDigits: frac, maximumFractionDigits: frac }).format(
|
||||||
|
Number(v ?? 0),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const datum = (d: Date | string | null | undefined) =>
|
||||||
|
d ? new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(new Date(d)) : "—";
|
||||||
|
|
||||||
|
export const datumZeit = (d: Date | string | null | undefined) =>
|
||||||
|
d ? new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(new Date(d)) : "—";
|
||||||
|
|
||||||
|
export const datumKurz = (d: Date | string | null | undefined) =>
|
||||||
|
d ? new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "2-digit" }).format(new Date(d)) : "—";
|
||||||
|
|
||||||
|
export function relativ(d: Date | string): string {
|
||||||
|
const t = new Date(d).getTime();
|
||||||
|
const diff = Date.now() - t;
|
||||||
|
const min = Math.round(diff / 60000);
|
||||||
|
if (min < 1) return "gerade eben";
|
||||||
|
if (min < 60) return `vor ${min} Min.`;
|
||||||
|
const h = Math.round(min / 60);
|
||||||
|
if (h < 24) return `vor ${h} Std.`;
|
||||||
|
const tage = Math.round(h / 24);
|
||||||
|
if (tage < 7) return `vor ${tage} Tg.`;
|
||||||
|
return datum(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tageBis(d: Date | null): number | null {
|
||||||
|
if (!d) return null;
|
||||||
|
return Math.ceil((new Date(d).getTime() - Date.now()) / 864e5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enum-Werte wie SACHLICH_RICHTIG lesbar machen. */
|
||||||
|
export function enumLabel(v: string): string {
|
||||||
|
return v
|
||||||
|
.split("_")
|
||||||
|
.map((w) => w.charAt(0) + w.slice(1).toLowerCase())
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Project, Uebergabezeitpunkt } from "@prisma/client";
|
||||||
|
|
||||||
|
export const UEBERGABE_LABEL: Record<Uebergabezeitpunkt, string> = {
|
||||||
|
INBETRIEBNAHME: "zur Inbetriebnahme",
|
||||||
|
BAUAKTE_II_IBN: "Bauakte Teil II (IBN-Akte)",
|
||||||
|
IBN_PLUS_3M: "3 Monate nach IBN",
|
||||||
|
IBN_PLUS_6M: "6 Monate nach IBN",
|
||||||
|
BAUAKTE_I_BAUENDE_6M: "Bauakte Teil I – 6 Monate nach Bauende",
|
||||||
|
PROJEKTABSCHLUSS: "zum Projektabschluss",
|
||||||
|
};
|
||||||
|
|
||||||
|
function plusMonths(d: Date, m: number) {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setMonth(x.getMonth() + m);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechnet den Übergabezeitpunkt aus der Ablagestruktur gegen die
|
||||||
|
* Projekttermine und liefert die konkrete Frist.
|
||||||
|
* Es gilt die früheste erreichbare Frist – so entsteht kein Stau am Projektende.
|
||||||
|
*/
|
||||||
|
export function berechneFrist(
|
||||||
|
uebergabe: Uebergabezeitpunkt[],
|
||||||
|
projekt: Pick<Project, "inbetriebnahme" | "bauende" | "projektabschluss" | "bauschlussmeldung">,
|
||||||
|
): Date | null {
|
||||||
|
const kandidaten: Date[] = [];
|
||||||
|
for (const u of uebergabe) {
|
||||||
|
switch (u) {
|
||||||
|
case "INBETRIEBNAHME":
|
||||||
|
case "BAUAKTE_II_IBN":
|
||||||
|
if (projekt.inbetriebnahme) kandidaten.push(projekt.inbetriebnahme);
|
||||||
|
break;
|
||||||
|
case "IBN_PLUS_3M":
|
||||||
|
if (projekt.inbetriebnahme) kandidaten.push(plusMonths(projekt.inbetriebnahme, 3));
|
||||||
|
break;
|
||||||
|
case "IBN_PLUS_6M":
|
||||||
|
if (projekt.inbetriebnahme) kandidaten.push(plusMonths(projekt.inbetriebnahme, 6));
|
||||||
|
break;
|
||||||
|
case "BAUAKTE_I_BAUENDE_6M":
|
||||||
|
if (projekt.bauende) kandidaten.push(plusMonths(projekt.bauende, 6));
|
||||||
|
break;
|
||||||
|
case "PROJEKTABSCHLUSS":
|
||||||
|
if (projekt.projektabschluss) kandidaten.push(projekt.projektabschluss);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (kandidaten.length === 0) return null;
|
||||||
|
return new Date(Math.min(...kandidaten.map((d) => d.getTime())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aufbewahrungsende gemäß Ablagestruktur (Jahre nach Bauschlussmeldung). */
|
||||||
|
export function berechneAufbewahrung(
|
||||||
|
jahre: string | null,
|
||||||
|
nutzungsdauer: boolean,
|
||||||
|
bauschlussmeldung: Date | null,
|
||||||
|
): { bis: Date | null; hinweis: string } {
|
||||||
|
if (nutzungsdauer) return { bis: null, hinweis: "Nutzungsdauer der Anlage" };
|
||||||
|
if (!jahre) return { bis: null, hinweis: "nicht festgelegt" };
|
||||||
|
const m = jahre.match(/(\d+)/);
|
||||||
|
if (!m) return { bis: null, hinweis: jahre };
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
if (!bauschlussmeldung) return { bis: null, hinweis: `${n} Jahre nach Bauschlussmeldung` };
|
||||||
|
const bis = new Date(bauschlussmeldung);
|
||||||
|
bis.setFullYear(bis.getFullYear() + n);
|
||||||
|
return { bis, hinweis: `${n} Jahre nach Bauschlussmeldung` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ampel = "GRUEN" | "GELB" | "ROT" | "GRAU";
|
||||||
|
|
||||||
|
export function fristAmpel(frist: Date | null, erledigt: boolean, heute = new Date()): Ampel {
|
||||||
|
if (erledigt) return "GRUEN";
|
||||||
|
if (!frist) return "GRAU";
|
||||||
|
const tage = Math.floor((frist.getTime() - heute.getTime()) / 864e5);
|
||||||
|
if (tage < 0) return "ROT";
|
||||||
|
if (tage <= 30) return "GELB";
|
||||||
|
return "GRUEN";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FACHBEREICHE_NETZ = [
|
||||||
|
"Oberbau",
|
||||||
|
"Tiefbau",
|
||||||
|
"Konstruktiver Ingenieurbau",
|
||||||
|
"Hochbau",
|
||||||
|
"Elektrotechnik 50 Hz",
|
||||||
|
"LST",
|
||||||
|
"Tk (inkl. HOA, FBOA)",
|
||||||
|
"Oberleitung 16,7 Hz",
|
||||||
|
"Maschinentechnik",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const FACHBEREICHE_BAHNHOF = [
|
||||||
|
"Hoch-, Tief- und Ingenieurbau/Verkehrsanlagen inkl. baulichem Brandschutz",
|
||||||
|
"HLS (KG 410/420/430/435)",
|
||||||
|
"Starkstromanlagen 50 Hz, Erdung (KG 440)",
|
||||||
|
"Blitzschutz (KG 446)",
|
||||||
|
"TK-Anlagen (KG 450)",
|
||||||
|
"Fördertechnik (KG 460)",
|
||||||
|
"Gebäudeautomation (KG 480)",
|
||||||
|
];
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import "server-only";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
let transport: nodemailer.Transporter | null = null;
|
||||||
|
|
||||||
|
function getTransport() {
|
||||||
|
if (transport) return transport;
|
||||||
|
transport = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || "mail",
|
||||||
|
port: Number(process.env.SMTP_PORT) || 1025,
|
||||||
|
secure: process.env.SMTP_SECURE === "true",
|
||||||
|
auth: process.env.SMTP_USER
|
||||||
|
? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS || "" }
|
||||||
|
: undefined,
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
return transport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendeMail(opts: {
|
||||||
|
an: string | string[];
|
||||||
|
betreff: string;
|
||||||
|
text: string;
|
||||||
|
html?: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
await getTransport().sendMail({
|
||||||
|
from: process.env.SMTP_FROM || "BauDoc Bahn <noreply@baudoc.local>",
|
||||||
|
to: Array.isArray(opts.an) ? opts.an.join(", ") : opts.an,
|
||||||
|
subject: opts.betreff,
|
||||||
|
text: opts.text,
|
||||||
|
html: opts.html ?? htmlRahmen(opts.betreff, opts.text),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[mail] Versand fehlgeschlagen:", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function htmlRahmen(titel: string, text: string, link?: string) {
|
||||||
|
const url = process.env.APP_URL || "http://localhost:3400";
|
||||||
|
return `<!doctype html><html><body style="margin:0;background:#f4f5f7;font-family:Segoe UI,Arial,sans-serif">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0"><tr><td align="center" style="padding:28px 12px">
|
||||||
|
<table width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:10px;overflow:hidden;border:1px solid #e2e5ea">
|
||||||
|
<tr><td style="background:#131821;padding:16px 22px;color:#fff;font-size:15px;font-weight:600">
|
||||||
|
BauDoc Bahn <span style="color:#8A94A6;font-weight:400">· Baustellen- und Dokumentenmanagement</span></td></tr>
|
||||||
|
<tr><td style="padding:22px">
|
||||||
|
<h1 style="margin:0 0 12px;font-size:17px;color:#131821">${escapeHtml(titel)}</h1>
|
||||||
|
<div style="font-size:14px;line-height:1.6;color:#3a4252;white-space:pre-wrap">${escapeHtml(text)}</div>
|
||||||
|
${link ? `<p style="margin:22px 0 0"><a href="${url}${link}" style="background:#EC0016;color:#fff;text-decoration:none;padding:10px 18px;border-radius:6px;font-size:14px;display:inline-block">Im System öffnen</a></p>` : ""}
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:14px 22px;background:#f8f9fb;color:#8A94A6;font-size:12px;border-top:1px solid #e2e5ea">
|
||||||
|
Automatische Nachricht. Benachrichtigungen lassen sich im Profil einstellen.</td></tr>
|
||||||
|
</table></td></tr></table></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s: string) {
|
||||||
|
return s.replace(/[&<>"']/g, (c) =>
|
||||||
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { prisma } from "./db";
|
||||||
|
import { sendeMail, htmlRahmen } from "./mail";
|
||||||
|
import type { NotificationType } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Benachrichtigung landet immer im System (Glocke) und – je nach
|
||||||
|
* Einstellung des Empfängers – zusätzlich per E-Mail. Damit ersetzt der
|
||||||
|
* interne Nachrichtenverkehr den E-Mail-Pingpong, ohne dass jemand etwas
|
||||||
|
* verpasst, der nur sein Postfach liest.
|
||||||
|
*/
|
||||||
|
export async function benachrichtige(opts: {
|
||||||
|
userIds: string[];
|
||||||
|
typ: NotificationType;
|
||||||
|
titel: string;
|
||||||
|
text?: string;
|
||||||
|
link?: string;
|
||||||
|
projectId?: string;
|
||||||
|
ausgeloestVon?: string;
|
||||||
|
}) {
|
||||||
|
const empfaenger = [...new Set(opts.userIds)].filter((id) => id !== opts.ausgeloestVon);
|
||||||
|
if (empfaenger.length === 0) return;
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where: { id: { in: empfaenger }, active: true },
|
||||||
|
select: {
|
||||||
|
id: true, email: true, name: true, notifyMode: true,
|
||||||
|
notifyMentions: true, notifyAssignments: true, notifyDeadlines: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.notification.createMany({
|
||||||
|
data: users.map((u) => ({
|
||||||
|
userId: u.id,
|
||||||
|
typ: opts.typ,
|
||||||
|
titel: opts.titel,
|
||||||
|
text: opts.text ?? null,
|
||||||
|
link: opts.link ?? null,
|
||||||
|
projectId: opts.projectId ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const sofort = users.filter((u) => {
|
||||||
|
if (u.notifyMode !== "SOFORT") return false;
|
||||||
|
if (opts.typ === "ERWAEHNUNG" && !u.notifyMentions) return false;
|
||||||
|
if (opts.typ === "ZUWEISUNG" && !u.notifyAssignments) return false;
|
||||||
|
if (opts.typ === "FRIST" && !u.notifyDeadlines) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
sofort.map((u) =>
|
||||||
|
sendeMail({
|
||||||
|
an: u.email,
|
||||||
|
betreff: opts.titel,
|
||||||
|
text: opts.text ?? opts.titel,
|
||||||
|
html: htmlRahmen(opts.titel, opts.text ?? "", opts.link),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @-Erwähnungen aus einem Nachrichtentext auflösen. */
|
||||||
|
export async function findeErwaehnungen(text: string, projectId?: string) {
|
||||||
|
const namen = [...text.matchAll(/@([\wäöüÄÖÜß.-]{2,40})/g)].map((m) => m[1].toLowerCase());
|
||||||
|
if (namen.length === 0) return [];
|
||||||
|
|
||||||
|
const kandidaten = await prisma.user.findMany({
|
||||||
|
where: projectId ? { memberships: { some: { projectId } } } : { active: true },
|
||||||
|
select: { id: true, name: true, email: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const treffer = new Set<string>();
|
||||||
|
for (const k of kandidaten) {
|
||||||
|
const handle = k.email.split("@")[0].toLowerCase();
|
||||||
|
const nachname = k.name.split(" ").slice(-1)[0].toLowerCase();
|
||||||
|
if (namen.some((n) => n === handle || n === nachname || k.name.toLowerCase().startsWith(n))) {
|
||||||
|
treffer.add(k.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...treffer];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function protokolliere(opts: {
|
||||||
|
userId?: string | null;
|
||||||
|
aktion: string;
|
||||||
|
entitaet: string;
|
||||||
|
entitaetId?: string;
|
||||||
|
projectId?: string;
|
||||||
|
details?: unknown;
|
||||||
|
}) {
|
||||||
|
await prisma.auditLog.create({
|
||||||
|
data: {
|
||||||
|
userId: opts.userId ?? null,
|
||||||
|
aktion: opts.aktion,
|
||||||
|
entitaet: opts.entitaet,
|
||||||
|
entitaetId: opts.entitaetId ?? null,
|
||||||
|
projectId: opts.projectId ?? null,
|
||||||
|
details: (opts.details ?? undefined) as never,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { prisma } from "./db";
|
||||||
|
import { berechneFrist } from "./fristen";
|
||||||
|
import type { ProjektOptionen } from "./rbac";
|
||||||
|
import type { Project } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt für ein Projekt die einschlägigen Soll-Dokumentenpositionen aus der
|
||||||
|
* EIU-Ablagestruktur.
|
||||||
|
*
|
||||||
|
* Einschlägig ist eine Unterlage, wenn sie
|
||||||
|
* 1. für die Bauherrenseite des Projekts gilt (Fahrweg bzw. Personenbahnhöfe) und
|
||||||
|
* 2. mindestens einem der im Projekt gewählten Fachbereiche zugeordnet ist –
|
||||||
|
* oder gar keinem, dann gilt sie generell.
|
||||||
|
*
|
||||||
|
* Die Frist ergibt sich aus dem Übergabezeitpunkt der Unterlage und den
|
||||||
|
* Projektterminen. Bestehende Positionen bleiben unangetastet; ein erneuter
|
||||||
|
* Aufruf ergänzt nur, was durch geänderte Fachbereiche hinzugekommen ist.
|
||||||
|
*/
|
||||||
|
export async function slotsErzeugen(projectId: string): Promise<{ neu: number; gesamt: number }> {
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
|
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
|
||||||
|
|
||||||
|
const requirements = await prisma.documentRequirement.findMany();
|
||||||
|
const gewaehlt = requirements.filter((r) => {
|
||||||
|
if (istBahnhof ? !r.relevantBahnhof : !r.relevantNetz) return false;
|
||||||
|
if (r.fachbereiche.length === 0) return true;
|
||||||
|
if (projekt.fachbereiche.length === 0) return true;
|
||||||
|
return r.fachbereiche.some((f) => projekt.fachbereiche.includes(f));
|
||||||
|
});
|
||||||
|
|
||||||
|
const ergebnis = await prisma.projectDocumentSlot.createMany({
|
||||||
|
data: gewaehlt.map((r) => ({
|
||||||
|
projectId,
|
||||||
|
requirementId: r.id,
|
||||||
|
frist: berechneFrist(r.uebergabe, projekt),
|
||||||
|
})),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { neu: ergebnis.count, gesamt: gewaehlt.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schreibt die Fristen aller offenen Positionen neu – nötig, wenn sich
|
||||||
|
* Projekttermine verschieben. Bereits freigegebene Positionen bleiben unberührt.
|
||||||
|
*/
|
||||||
|
export async function fristenNeuBerechnen(projectId: string): Promise<number> {
|
||||||
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
|
const slots = await prisma.projectDocumentSlot.findMany({
|
||||||
|
where: { projectId, status: { notIn: ["FREIGEGEBEN", "ENTFAELLT"] } },
|
||||||
|
include: { requirement: { select: { uebergabe: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
let geaendert = 0;
|
||||||
|
for (const s of slots) {
|
||||||
|
const frist = berechneFrist(s.requirement.uebergabe, projekt);
|
||||||
|
if (frist?.getTime() !== s.frist?.getTime()) {
|
||||||
|
await prisma.projectDocumentSlot.update({ where: { id: s.id }, data: { frist } });
|
||||||
|
geaendert++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return geaendert;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Meilensteine aus den Projektterminen ableiten. */
|
||||||
|
export function meilensteineAusTerminen(p: {
|
||||||
|
startgespraech?: Date | null;
|
||||||
|
baubeginn?: Date | null;
|
||||||
|
inbetriebnahme?: Date | null;
|
||||||
|
bauende?: Date | null;
|
||||||
|
bauschlussmeldung?: Date | null;
|
||||||
|
projektabschluss?: Date | null;
|
||||||
|
}) {
|
||||||
|
const liste: { typ: never; titel: string; geplant: Date }[] = [];
|
||||||
|
const add = (typ: string, titel: string, d?: Date | null) => {
|
||||||
|
if (d) liste.push({ typ: typ as never, titel, geplant: d });
|
||||||
|
};
|
||||||
|
add("STARTGESPRAECH", "Startgespräch Bauüberwachung", p.startgespraech);
|
||||||
|
add("BAUBEGINN", "Baubeginn / Bauanlaufberatung", p.baubeginn);
|
||||||
|
add("INBETRIEBNAHME", "Inbetriebnahme", p.inbetriebnahme);
|
||||||
|
add("BAUENDE", "Bauende", p.bauende);
|
||||||
|
add("BAUSCHLUSSMELDUNG", "Bauschlussmeldung", p.bauschlussmeldung);
|
||||||
|
add("PROJEKTABSCHLUSS", "Projektabschluss (PA)", p.projektabschluss);
|
||||||
|
return liste;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionenAus(projekt: Project): ProjektOptionen {
|
||||||
|
return {
|
||||||
|
anSichtVertrag: projekt.anSichtVertrag,
|
||||||
|
anSichtRechnungen: projekt.anSichtRechnungen,
|
||||||
|
anSichtBautagebuch: projekt.anSichtBautagebuch,
|
||||||
|
anSichtDokumentation: projekt.anSichtDokumentation,
|
||||||
|
anDarfDokumente: projekt.anDarfDokumente,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wie viele Soll-Unterlagen bringt welche Fachbereichsauswahl mit sich?
|
||||||
|
* Wird als Bitmaske an das Anlageformular gegeben, damit die Vorschau ohne
|
||||||
|
* Serveranfrage mitzählt.
|
||||||
|
*/
|
||||||
|
export async function fachbereichsMasken(alleFachbereiche: string[]) {
|
||||||
|
const requirements = await prisma.documentRequirement.findMany({
|
||||||
|
select: { relevantNetz: true, relevantBahnhof: true, fachbereiche: true },
|
||||||
|
});
|
||||||
|
const index = new Map(alleFachbereiche.map((f, i) => [f, i]));
|
||||||
|
|
||||||
|
const maske = (fb: string[]) =>
|
||||||
|
fb.reduce((m, f) => (index.has(f) ? m | (1 << index.get(f)!) : m), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
netz: requirements.filter((r) => r.relevantNetz).map((r) => maske(r.fachbereiche)),
|
||||||
|
bahnhof: requirements.filter((r) => r.relevantBahnhof).map((r) => maske(r.fachbereiche)),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigungsmatrix. Bewusst als Code und nicht als Datenbanktabelle:
|
||||||
|
* so ist jederzeit nachvollziehbar und im Review prüfbar, wer was darf.
|
||||||
|
* Feinsteuerung pro Projekt erfolgt über die Rollenzuordnung (ProjectMember).
|
||||||
|
*/
|
||||||
|
export const PERMISSIONS = {
|
||||||
|
// Projekt & Stammdaten
|
||||||
|
"projekt.anlegen": ["ADMIN", "PROJEKTLEITER"],
|
||||||
|
"projekt.bearbeiten": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||||
|
"projekt.mitglieder": ["ADMIN", "PROJEKTLEITER"],
|
||||||
|
|
||||||
|
// Dokumentenmanagement
|
||||||
|
"dokument.lesen": [
|
||||||
|
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||||
|
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "AUFTRAGNEHMER", "PRUEFER",
|
||||||
|
"BETREIBER", "LESER",
|
||||||
|
],
|
||||||
|
"dokument.hochladen": [
|
||||||
|
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||||
|
"FACHBAUUEBERWACHER", "PLANER", "AUFTRAGNEHMER", "KAUFMANN",
|
||||||
|
],
|
||||||
|
"dokument.freigeben": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||||
|
"dokument.loeschen": ["ADMIN", "PROJEKTLEITER"],
|
||||||
|
"dokument.intern": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "KAUFMANN"],
|
||||||
|
|
||||||
|
// Ablagestruktur / Soll-Ist
|
||||||
|
"ablage.lesen": [
|
||||||
|
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||||
|
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "PRUEFER", "BETREIBER", "LESER",
|
||||||
|
],
|
||||||
|
"ablage.steuern": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER"],
|
||||||
|
|
||||||
|
// Bautagebuch
|
||||||
|
"bautagebuch.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "KAUFMANN", "PRUEFER", "LESER"],
|
||||||
|
"bautagebuch.schreiben": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||||
|
"bautagebuch.gegenzeichnen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||||
|
|
||||||
|
// Meldungen / Mängel
|
||||||
|
"vorgang.anlegen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER", "AUFTRAGNEHMER", "PRUEFER"],
|
||||||
|
"vorgang.bearbeiten": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||||
|
"vorgang.schliessen": ["ADMIN", "PROJEKTLEITER", "BAUUEBERWACHER"],
|
||||||
|
|
||||||
|
// Verträge & Nachträge
|
||||||
|
"vertrag.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "KAUFMANN"],
|
||||||
|
"vertrag.bearbeiten": ["ADMIN", "PROJEKTLEITER", "KAUFMANN"],
|
||||||
|
"nachtrag.pruefen": ["ADMIN", "BAUUEBERWACHER", "PROJEKTLEITER"],
|
||||||
|
|
||||||
|
// Stundenzettel & Aufmaß
|
||||||
|
"stunden.einreichen": ["ADMIN", "AUFTRAGNEHMER"],
|
||||||
|
"stunden.pruefen": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||||
|
"aufmass.einreichen": ["ADMIN", "AUFTRAGNEHMER"],
|
||||||
|
"aufmass.pruefen": ["ADMIN", "BAUUEBERWACHER", "FACHBAUUEBERWACHER"],
|
||||||
|
|
||||||
|
// Rechnungsprüfung – bewusste Vier-Augen-Trennung
|
||||||
|
"rechnung.erfassen": ["ADMIN", "KAUFMANN", "AUFTRAGNEHMER"],
|
||||||
|
"rechnung.lesen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER", "KAUFMANN"],
|
||||||
|
"rechnung.sachlich": ["ADMIN", "BAUUEBERWACHER"], // "sachlich richtig"
|
||||||
|
"rechnung.rechnerisch": ["ADMIN", "KAUFMANN"], // "rechnerisch richtig"
|
||||||
|
"rechnung.freigeben": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER"],
|
||||||
|
|
||||||
|
// Kommunikation
|
||||||
|
"chat.schreiben": [
|
||||||
|
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||||
|
"FACHBAUUEBERWACHER", "KAUFMANN", "PLANER", "AUFTRAGNEHMER", "PRUEFER", "BETREIBER",
|
||||||
|
],
|
||||||
|
"chat.kanal.anlegen": ["ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER"],
|
||||||
|
|
||||||
|
// Administration
|
||||||
|
"admin.benutzer": ["ADMIN"],
|
||||||
|
"admin.firmen": ["ADMIN", "PROJEKTLEITER"],
|
||||||
|
"admin.audit": ["ADMIN"],
|
||||||
|
} as const satisfies Record<string, readonly Role[]>;
|
||||||
|
|
||||||
|
export type Permission = keyof typeof PERMISSIONS;
|
||||||
|
|
||||||
|
export function can(roles: Role[], permission: Permission): boolean {
|
||||||
|
const allowed = PERMISSIONS[permission] as readonly Role[];
|
||||||
|
return roles.some((r) => allowed.includes(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wirft, wenn die Berechtigung fehlt – für Server Actions und API-Routen. */
|
||||||
|
export function assertCan(roles: Role[], permission: Permission) {
|
||||||
|
if (!can(roles, permission)) {
|
||||||
|
throw new Error(`Keine Berechtigung für "${permission}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ROLE_LABEL: Record<Role, string> = {
|
||||||
|
ADMIN: "Administration",
|
||||||
|
PROJEKTLEITER: "Projektleitung (PL)",
|
||||||
|
BAUHERRENVERTRETER: "Bauherrenvertretung (BHV)",
|
||||||
|
BAUUEBERWACHER: "Bauüberwacher Bahn (BÜB)",
|
||||||
|
FACHBAUUEBERWACHER: "Fachbauüberwacher (FBÜ)",
|
||||||
|
KAUFMANN: "Kaufmännische Steuerung",
|
||||||
|
PLANER: "Planung",
|
||||||
|
AUFTRAGNEHMER: "Auftragnehmer",
|
||||||
|
PRUEFER: "Prüfer / Gutachter",
|
||||||
|
BETREIBER: "Anlagenbetreiber",
|
||||||
|
LESER: "Lesezugriff",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_HINT: Record<Role, string> = {
|
||||||
|
ADMIN: "Vollzugriff inkl. Benutzerverwaltung und Protokoll.",
|
||||||
|
PROJEKTLEITER: "Steuert Projekt, Termine, Freigaben und Rollen.",
|
||||||
|
BAUHERRENVERTRETER: "Vertritt den Bauherrn vor Ort, zeichnet gegen, gibt Rechnungen frei.",
|
||||||
|
BAUUEBERWACHER: "Führt Bautagebuch, prüft Stunden und Aufmaß, bescheinigt sachliche Richtigkeit.",
|
||||||
|
FACHBAUUEBERWACHER: "Wie BÜB, beschränkt auf zugewiesene Fachbereiche.",
|
||||||
|
KAUFMANN: "Verträge, Nachträge, rechnerische Prüfung, Zahlungsfreigabe.",
|
||||||
|
PLANER: "Liefert Planunterlagen in die Ablagestruktur.",
|
||||||
|
AUFTRAGNEHMER: "Sieht ausschließlich eigene Verträge, Stunden, Aufmaße, Rechnungen.",
|
||||||
|
PRUEFER: "Gutachten und Prüfberichte, lesender Zugriff auf Fachunterlagen.",
|
||||||
|
BETREIBER: "Zugriff auf die Betreiberakte (Teil II) nach Übergabe.",
|
||||||
|
LESER: "Nur lesen.",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auftragnehmer und andere Externe sehen ausschließlich Daten der eigenen Firma.
|
||||||
|
*
|
||||||
|
* Wichtig: Diese Funktion steuert die DATENAUSWAHL und darf niemals durch eine
|
||||||
|
* Projekteinstellung aufgeweicht werden. Die Schalter unter `ProjektOptionen`
|
||||||
|
* entscheiden nur, ob ein Bereich überhaupt sichtbar ist – nicht, wessen Daten
|
||||||
|
* darin erscheinen.
|
||||||
|
*/
|
||||||
|
export function isExternalOnly(roles: Role[]): boolean {
|
||||||
|
const privileged: Role[] = [
|
||||||
|
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
||||||
|
"FACHBAUUEBERWACHER", "KAUFMANN",
|
||||||
|
];
|
||||||
|
return roles.length > 0 && !roles.some((r) => privileged.includes(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Projektbezogene Einstellungen
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Je Projekt einstellbar: welche Bereiche für Auftragnehmer sichtbar sind.
|
||||||
|
* Damit entscheidet der Bauherr selbst, wie offen er gegenüber seinen
|
||||||
|
* Baufirmen arbeiten möchte – statt dass es im Code festgeschrieben ist.
|
||||||
|
*/
|
||||||
|
export type ProjektOptionen = {
|
||||||
|
anSichtVertrag: boolean;
|
||||||
|
anSichtRechnungen: boolean;
|
||||||
|
anSichtBautagebuch: boolean;
|
||||||
|
anSichtDokumentation: boolean;
|
||||||
|
anDarfDokumente: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OPTION_INFO: {
|
||||||
|
feld: keyof ProjektOptionen;
|
||||||
|
titel: string;
|
||||||
|
hinweis: string;
|
||||||
|
standard: boolean;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
feld: "anSichtVertrag",
|
||||||
|
titel: "Eigenen Vertrag einsehen",
|
||||||
|
hinweis:
|
||||||
|
"Die Baufirma sieht ihr eigenes Leistungsverzeichnis und die eigenen Nachträge samt Stellungnahme der Bauüberwachung. Verträge anderer Firmen bleiben verborgen.",
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feld: "anSichtRechnungen",
|
||||||
|
titel: "Stand der eigenen Rechnungen verfolgen",
|
||||||
|
hinweis:
|
||||||
|
"Die Baufirma sieht, wo ihre Rechnung steht und welche Prüfbefunde es gibt. Das erspart Rückfragen – macht Kürzungen aber sofort sichtbar.",
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feld: "anSichtBautagebuch",
|
||||||
|
titel: "Bautagebuch lesen",
|
||||||
|
hinweis:
|
||||||
|
"Das Bautagebuch ist die Aufzeichnung des Bauherrn. Leserecht schafft Transparenz, kann bei Streitfällen aber unerwünscht sein.",
|
||||||
|
standard: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feld: "anSichtDokumentation",
|
||||||
|
titel: "Dokumentationsstand einsehen",
|
||||||
|
hinweis:
|
||||||
|
"Zeigt der Baufirma, welche Unterlagen aus der Ablagestruktur noch fehlen. Sinnvoll, wenn sie selbst zuliefern soll.",
|
||||||
|
standard: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
feld: "anDarfDokumente",
|
||||||
|
titel: "Unterlagen einstellen",
|
||||||
|
hinweis:
|
||||||
|
"Erlaubt der Baufirma, Nachweise, Prüfprotokolle und Bestandsunterlagen direkt hochzuladen, statt sie per E-Mail zu schicken.",
|
||||||
|
standard: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Welches Recht durch welchen Projektschalter zusätzlich gewährt wird. */
|
||||||
|
const OPTIONAL_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>> = {
|
||||||
|
"vertrag.lesen": "anSichtVertrag",
|
||||||
|
"rechnung.lesen": "anSichtRechnungen",
|
||||||
|
"bautagebuch.lesen": "anSichtBautagebuch",
|
||||||
|
"ablage.lesen": "anSichtDokumentation",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Rechte, die einem Externen durch einen Projektschalter entzogen werden können. */
|
||||||
|
const ENTZIEHBAR_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>> = {
|
||||||
|
"dokument.hochladen": "anDarfDokumente",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Berechtigung unter Berücksichtigung der Projekteinstellungen.
|
||||||
|
* Für Rollen der Bauherrenseite identisch mit `can()`.
|
||||||
|
*/
|
||||||
|
export function canInProject(
|
||||||
|
roles: Role[],
|
||||||
|
permission: Permission,
|
||||||
|
optionen: ProjektOptionen,
|
||||||
|
): boolean {
|
||||||
|
const extern = isExternalOnly(roles);
|
||||||
|
|
||||||
|
if (extern) {
|
||||||
|
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
||||||
|
if (entzug && !optionen[entzug]) return false;
|
||||||
|
|
||||||
|
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
||||||
|
if (gewaehrung && optionen[gewaehrung]) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return can(roles, permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertCanInProject(
|
||||||
|
roles: Role[],
|
||||||
|
permission: Permission,
|
||||||
|
optionen: ProjektOptionen,
|
||||||
|
) {
|
||||||
|
if (!canInProject(roles, permission, optionen)) {
|
||||||
|
throw new Error(`Keine Berechtigung für "${permission}".`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
import { prisma } from "./db";
|
||||||
|
import type { FindingKind, FindingSeverity, Prisma } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatischer Abgleich einer Rechnung gegen ihre Belege.
|
||||||
|
*
|
||||||
|
* Geprüft wird gegen genau die Unterlagen, die die Bauüberwachung ohnehin
|
||||||
|
* führen muss:
|
||||||
|
* • Vertrag / LV-Positionen (Einheitspreise, Auftragssumme)
|
||||||
|
* • anerkannte Aufmaße (Mengen, REB)
|
||||||
|
* • bestätigte Stundenlohnzettel (Stunden, Sätze, Zuschläge)
|
||||||
|
* • beauftragte Nachträge
|
||||||
|
* • bereits abgerechnete Mengen aus früheren Rechnungen
|
||||||
|
*
|
||||||
|
* Das Ergebnis ist ein Prüfbericht mit Befunden – die Entscheidung
|
||||||
|
* ("sachlich richtig") trifft weiterhin ein Mensch.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EPS = 0.005;
|
||||||
|
|
||||||
|
function n(v: Prisma.Decimal | number | null | undefined): number {
|
||||||
|
if (v === null || v === undefined) return 0;
|
||||||
|
return typeof v === "number" ? v : Number(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
const eur = (v: number) =>
|
||||||
|
new Intl.NumberFormat("de-DE", { style: "currency", currency: "EUR" }).format(v);
|
||||||
|
const num = (v: number, frac = 3) =>
|
||||||
|
new Intl.NumberFormat("de-DE", { maximumFractionDigits: frac }).format(v);
|
||||||
|
|
||||||
|
type NeuerBefund = {
|
||||||
|
lineId?: string | null;
|
||||||
|
art: FindingKind;
|
||||||
|
schwere: FindingSeverity;
|
||||||
|
meldung: string;
|
||||||
|
erwartet?: string;
|
||||||
|
gefunden?: string;
|
||||||
|
differenz?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Pruefbericht = {
|
||||||
|
befunde: number;
|
||||||
|
kritisch: number;
|
||||||
|
warnungen: number;
|
||||||
|
anerkanntNetto: number;
|
||||||
|
kuerzungNetto: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function pruefeRechnung(invoiceId: string): Promise<Pruefbericht> {
|
||||||
|
const invoice = await prisma.invoice.findUniqueOrThrow({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
include: {
|
||||||
|
zeilen: { orderBy: { position: "asc" } },
|
||||||
|
contract: { include: { positionen: true, nachtraege: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const befunde: NeuerBefund[] = [];
|
||||||
|
const lineUpdates: { id: string; mengeAnerkannt: number; betragAnerkannt: number }[] = [];
|
||||||
|
|
||||||
|
// ── Bezugsdaten laden ────────────────────────────────────────────
|
||||||
|
const contract = invoice.contract;
|
||||||
|
|
||||||
|
const aufmasse = contract
|
||||||
|
? await prisma.measurementLine.findMany({
|
||||||
|
where: {
|
||||||
|
measurement: {
|
||||||
|
contractId: contract.id,
|
||||||
|
status: { in: ["ANERKANNT", "TEILWEISE_ANERKANNT"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { measurement: { select: { nummer: true, status: true } } },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/** anerkannte Menge je LV-Position aus allen freigegebenen Aufmaßen */
|
||||||
|
const aufmassMenge = new Map<string, number>();
|
||||||
|
for (const z of aufmasse) {
|
||||||
|
if (!z.contractItemId) continue;
|
||||||
|
const menge = z.mengeAnerkannt !== null ? n(z.mengeAnerkannt) : n(z.menge);
|
||||||
|
aufmassMenge.set(z.contractItemId, (aufmassMenge.get(z.contractItemId) ?? 0) + menge);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** bereits in ANDEREN Rechnungen abgerechnete Mengen je LV-Position */
|
||||||
|
const vorherigeZeilen = contract
|
||||||
|
? await prisma.invoiceLine.findMany({
|
||||||
|
where: {
|
||||||
|
invoiceId: { not: invoice.id },
|
||||||
|
invoice: { contractId: contract.id, status: { not: "ZURUECKGEWIESEN" } },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
contractItemId: true,
|
||||||
|
timeSheetId: true,
|
||||||
|
menge: true,
|
||||||
|
mengeAnerkannt: true,
|
||||||
|
invoice: { select: { rechnungsNr: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const bereitsAbgerechnet = new Map<string, number>();
|
||||||
|
const stundenzettelBenutztIn = new Map<string, string>();
|
||||||
|
for (const z of vorherigeZeilen) {
|
||||||
|
if (z.contractItemId) {
|
||||||
|
const m = z.mengeAnerkannt !== null ? n(z.mengeAnerkannt) : n(z.menge);
|
||||||
|
bereitsAbgerechnet.set(z.contractItemId, (bereitsAbgerechnet.get(z.contractItemId) ?? 0) + m);
|
||||||
|
}
|
||||||
|
if (z.timeSheetId) stundenzettelBenutztIn.set(z.timeSheetId, z.invoice.rechnungsNr);
|
||||||
|
}
|
||||||
|
|
||||||
|
const positionByOz = new Map(contract?.positionen.map((p) => [p.oz.trim(), p]) ?? []);
|
||||||
|
const positionById = new Map(contract?.positionen.map((p) => [p.id, p]) ?? []);
|
||||||
|
|
||||||
|
const stundenzettel = contract
|
||||||
|
? await prisma.timeSheet.findMany({
|
||||||
|
where: { contractId: contract.id },
|
||||||
|
include: { zeilen: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const zettelById = new Map(stundenzettel.map((t) => [t.id, t]));
|
||||||
|
const zettelByNr = new Map(stundenzettel.map((t) => [t.nummer.trim().toLowerCase(), t]));
|
||||||
|
|
||||||
|
const nachtragById = new Map(contract?.nachtraege.map((c) => [c.id, c]) ?? []);
|
||||||
|
|
||||||
|
// ── Zeilenweise Prüfung ──────────────────────────────────────────
|
||||||
|
if (!contract) {
|
||||||
|
befunde.push({
|
||||||
|
art: "KEIN_VERTRAGSBEZUG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: "Der Rechnung ist kein Vertrag zugeordnet. Eine sachliche Prüfung ist nicht möglich.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const zettelInDieserRechnung = new Set<string>();
|
||||||
|
|
||||||
|
for (const zeile of invoice.zeilen) {
|
||||||
|
const menge = n(zeile.menge);
|
||||||
|
const ep = n(zeile.einheitspreis);
|
||||||
|
const betrag = n(zeile.betrag);
|
||||||
|
|
||||||
|
// Rechnerische Plausibilität der Zeile
|
||||||
|
if (Math.abs(menge * ep - betrag) > 0.02) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "RECHENFEHLER",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Zeile ${zeile.position}: Menge × Einheitspreis ergibt nicht den ausgewiesenen Betrag.`,
|
||||||
|
erwartet: eur(menge * ep),
|
||||||
|
gefunden: eur(betrag),
|
||||||
|
differenz: betrag - menge * ep,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let mengeAnerkannt = menge;
|
||||||
|
let epAnerkannt = ep;
|
||||||
|
|
||||||
|
// ── LV-Position ────────────────────────────────────────────────
|
||||||
|
if (zeile.art === "LV_POSITION" || zeile.art === "MATERIAL") {
|
||||||
|
const pos =
|
||||||
|
(zeile.contractItemId ? positionById.get(zeile.contractItemId) : undefined) ??
|
||||||
|
(zeile.oz ? positionByOz.get(zeile.oz.trim()) : undefined);
|
||||||
|
|
||||||
|
if (!pos) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "KEIN_VERTRAGSBEZUG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Zeile ${zeile.position} „${zeile.bezeichnung}“: keine passende LV-Position im Vertrag (OZ ${zeile.oz ?? "—"}).`,
|
||||||
|
gefunden: zeile.oz ?? "ohne OZ",
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
epAnerkannt = 0;
|
||||||
|
} else {
|
||||||
|
const vertragsEp = n(pos.einheitspreis);
|
||||||
|
if (Math.abs(vertragsEp - ep) > EPS) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "PREIS_ABWEICHUNG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `OZ ${pos.oz}: abgerechneter Einheitspreis weicht vom Vertrag ab.`,
|
||||||
|
erwartet: eur(vertragsEp),
|
||||||
|
gefunden: eur(ep),
|
||||||
|
differenz: (ep - vertragsEp) * menge,
|
||||||
|
});
|
||||||
|
epAnerkannt = vertragsEp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geprueft = aufmassMenge.get(pos.id);
|
||||||
|
const schonAbgerechnet = bereitsAbgerechnet.get(pos.id) ?? 0;
|
||||||
|
|
||||||
|
if (geprueft === undefined) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "MENGE_OHNE_AUFMASS",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `OZ ${pos.oz}: es liegt kein anerkanntes Aufmaß vor. Menge ${num(menge)} ${zeile.einheit} nicht belegt.`,
|
||||||
|
gefunden: `${num(menge)} ${zeile.einheit}`,
|
||||||
|
differenz: menge * epAnerkannt,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
} else {
|
||||||
|
const verfuegbar = Math.max(0, geprueft - schonAbgerechnet);
|
||||||
|
if (menge - verfuegbar > 0.001) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: verfuegbar === 0 ? "MENGE_OHNE_AUFMASS" : "MENGE_UEBER_AUFMASS",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung:
|
||||||
|
verfuegbar === 0
|
||||||
|
? `OZ ${pos.oz}: die Leistung ist im Aufmaß enthalten, aber mit 0 anerkannt` +
|
||||||
|
(schonAbgerechnet > 0 ? ` bzw. bereits vollständig abgerechnet.` : " (noch nicht erbracht).")
|
||||||
|
: `OZ ${pos.oz}: abgerechnete Menge übersteigt das anerkannte Aufmaß` +
|
||||||
|
(schonAbgerechnet > 0 ? ` (davon ${num(schonAbgerechnet)} bereits in Vorrechnungen).` : "."),
|
||||||
|
erwartet: `${num(verfuegbar)} ${zeile.einheit}`,
|
||||||
|
gefunden: `${num(menge)} ${zeile.einheit}`,
|
||||||
|
differenz: (menge - verfuegbar) * epAnerkannt,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = verfuegbar;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mengenmehrung über LV hinaus ist nicht automatisch unzulässig,
|
||||||
|
// aber immer ein Hinweis (§ 2 Abs. 3 VOB/B).
|
||||||
|
const lvMenge = n(pos.menge);
|
||||||
|
if (lvMenge > 0 && schonAbgerechnet + menge > lvMenge * 1.1) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "HINWEIS",
|
||||||
|
schwere: "WARNUNG",
|
||||||
|
meldung: `OZ ${pos.oz}: Mengenmehrung über 110 % der LV-Menge – Preisanpassung nach § 2 Abs. 3 VOB/B prüfen.`,
|
||||||
|
erwartet: `${num(lvMenge)} ${zeile.einheit} (LV)`,
|
||||||
|
gefunden: `${num(schonAbgerechnet + menge)} ${zeile.einheit} (kumuliert)`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Stundenlohn ────────────────────────────────────────────────
|
||||||
|
if (zeile.art === "STUNDENLOHN") {
|
||||||
|
const zettel =
|
||||||
|
(zeile.timeSheetId ? zettelById.get(zeile.timeSheetId) : undefined) ??
|
||||||
|
(zeile.oz ? zettelByNr.get(zeile.oz.trim().toLowerCase()) : undefined);
|
||||||
|
|
||||||
|
if (!zettel) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "STUNDEN_OHNE_ZETTEL",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Zeile ${zeile.position}: ${num(menge, 2)} Std. ohne zugeordneten Stundenlohnzettel abgerechnet.`,
|
||||||
|
gefunden: `${num(menge, 2)} Std.`,
|
||||||
|
differenz: betrag,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
} else {
|
||||||
|
if (zettelInDieserRechnung.has(zettel.id)) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "DOPPELABRECHNUNG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Stundenlohnzettel ${zettel.nummer} wird in dieser Rechnung mehrfach abgerechnet.`,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
}
|
||||||
|
zettelInDieserRechnung.add(zettel.id);
|
||||||
|
|
||||||
|
const frueher = stundenzettelBenutztIn.get(zettel.id);
|
||||||
|
if (frueher) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "DOPPELABRECHNUNG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Stundenlohnzettel ${zettel.nummer} wurde bereits mit Rechnung ${frueher} abgerechnet.`,
|
||||||
|
differenz: betrag,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zettel.status !== "ANERKANNT" && zettel.status !== "TEILWEISE_ANERKANNT") {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "ZETTEL_NICHT_ANERKANNT",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Stundenlohnzettel ${zettel.nummer} ist nicht von der Bauüberwachung bestätigt (Status: ${zettel.status}).`,
|
||||||
|
erwartet: "ANERKANNT",
|
||||||
|
gefunden: zettel.status,
|
||||||
|
differenz: betrag,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
} else {
|
||||||
|
const anerkannteStunden = zettel.zeilen.reduce(
|
||||||
|
(s, z) => s + (z.stundenAnerkannt !== null ? n(z.stundenAnerkannt) : n(z.stundenGemeldet)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const gemeldeteStunden = zettel.zeilen.reduce((s, z) => s + n(z.stundenGemeldet), 0);
|
||||||
|
|
||||||
|
if (menge - anerkannteStunden > 0.01) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "STUNDEN_UEBER_ZETTEL",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung:
|
||||||
|
`Zettel ${zettel.nummer}: abgerechnet ${num(menge, 2)} Std., ` +
|
||||||
|
`von der Bauüberwachung anerkannt ${num(anerkannteStunden, 2)} Std.` +
|
||||||
|
(gemeldeteStunden !== anerkannteStunden
|
||||||
|
? ` (gemeldet waren ${num(gemeldeteStunden, 2)} Std.)`
|
||||||
|
: ""),
|
||||||
|
erwartet: `${num(anerkannteStunden, 2)} Std.`,
|
||||||
|
gefunden: `${num(menge, 2)} Std.`,
|
||||||
|
differenz: (menge - anerkannteStunden) * ep,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = Math.min(mengeAnerkannt, anerkannteStunden);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stundensatz: gewichteter Mittelwert der anerkannten Zeilen
|
||||||
|
const gewichtet = zettel.zeilen.reduce(
|
||||||
|
(acc, z) => {
|
||||||
|
const std = z.stundenAnerkannt !== null ? n(z.stundenAnerkannt) : n(z.stundenGemeldet);
|
||||||
|
const satz = n(z.stundensatz) * (1 + n(z.zuschlagProzent) / 100);
|
||||||
|
return { std: acc.std + std, summe: acc.summe + std * satz };
|
||||||
|
},
|
||||||
|
{ std: 0, summe: 0 },
|
||||||
|
);
|
||||||
|
const sollSatz = gewichtet.std > 0 ? gewichtet.summe / gewichtet.std : 0;
|
||||||
|
|
||||||
|
if (sollSatz > 0 && Math.abs(sollSatz - ep) > 0.01) {
|
||||||
|
const hatZuschlag = zettel.zeilen.some((z) => n(z.zuschlagProzent) > 0);
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: ep > sollSatz && !hatZuschlag ? "ZUSCHLAG_UNBELEGT" : "STUNDENSATZ_ABWEICHUNG",
|
||||||
|
schwere: ep > sollSatz ? "KRITISCH" : "WARNUNG",
|
||||||
|
meldung:
|
||||||
|
`Zettel ${zettel.nummer}: abgerechneter Stundensatz weicht vom bestätigten Satz ab` +
|
||||||
|
(ep > sollSatz && !hatZuschlag ? " – kein Zuschlag auf dem Zettel vermerkt." : "."),
|
||||||
|
erwartet: eur(sollSatz),
|
||||||
|
gefunden: eur(ep),
|
||||||
|
differenz: (ep - sollSatz) * Math.min(menge, mengeAnerkannt),
|
||||||
|
});
|
||||||
|
if (ep > sollSatz) epAnerkannt = sollSatz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Nachtrag ───────────────────────────────────────────────────
|
||||||
|
if (zeile.art === "NACHTRAG") {
|
||||||
|
const nt = zeile.changeOrderId ? nachtragById.get(zeile.changeOrderId) : undefined;
|
||||||
|
if (!nt) {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "NACHTRAG_NICHT_BEAUFTRAGT",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Zeile ${zeile.position}: Nachtragsleistung ohne zugeordneten Nachtrag abgerechnet.`,
|
||||||
|
differenz: betrag,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
} else if (nt.status !== "BEAUFTRAGT") {
|
||||||
|
befunde.push({
|
||||||
|
lineId: zeile.id,
|
||||||
|
art: "NACHTRAG_NICHT_BEAUFTRAGT",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: `Nachtrag ${nt.nummer} „${nt.titel}“ ist nicht beauftragt (Status: ${nt.status}).`,
|
||||||
|
erwartet: "BEAUFTRAGT",
|
||||||
|
gefunden: nt.status,
|
||||||
|
differenz: betrag,
|
||||||
|
});
|
||||||
|
mengeAnerkannt = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lineUpdates.push({
|
||||||
|
id: zeile.id,
|
||||||
|
mengeAnerkannt,
|
||||||
|
betragAnerkannt: Math.round(mengeAnerkannt * epAnerkannt * 100) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rechnungsebene ───────────────────────────────────────────────
|
||||||
|
const summeZeilen = invoice.zeilen.reduce((s, z) => s + n(z.betrag), 0);
|
||||||
|
const netto = n(invoice.nettoBetrag);
|
||||||
|
if (invoice.zeilen.length > 0 && Math.abs(summeZeilen - netto) > 0.02) {
|
||||||
|
befunde.push({
|
||||||
|
art: "RECHENFEHLER",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung: "Die Summe der Rechnungspositionen entspricht nicht dem ausgewiesenen Nettobetrag.",
|
||||||
|
erwartet: eur(summeZeilen),
|
||||||
|
gefunden: eur(netto),
|
||||||
|
differenz: netto - summeZeilen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ustSoll = Math.round(netto * (n(invoice.ustSatz) / 100) * 100) / 100;
|
||||||
|
if (Math.abs(ustSoll - n(invoice.ustBetrag)) > 0.02) {
|
||||||
|
befunde.push({
|
||||||
|
art: "RECHENFEHLER",
|
||||||
|
schwere: "WARNUNG",
|
||||||
|
meldung: `Umsatzsteuer rechnerisch nicht korrekt (${num(n(invoice.ustSatz), 2)} %).`,
|
||||||
|
erwartet: eur(ustSoll),
|
||||||
|
gefunden: eur(n(invoice.ustBetrag)),
|
||||||
|
differenz: n(invoice.ustBetrag) - ustSoll,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Math.abs(netto + n(invoice.ustBetrag) - n(invoice.bruttoBetrag)) > 0.02) {
|
||||||
|
befunde.push({
|
||||||
|
art: "RECHENFEHLER",
|
||||||
|
schwere: "WARNUNG",
|
||||||
|
meldung: "Bruttobetrag entspricht nicht Netto zuzüglich Umsatzsteuer.",
|
||||||
|
erwartet: eur(netto + n(invoice.ustBetrag)),
|
||||||
|
gefunden: eur(n(invoice.bruttoBetrag)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auftragssumme inkl. beauftragter Nachträge
|
||||||
|
if (contract) {
|
||||||
|
const nachtragssumme = contract.nachtraege
|
||||||
|
.filter((nt) => nt.status === "BEAUFTRAGT")
|
||||||
|
.reduce((s, nt) => s + n(nt.anerkannteSumme ?? nt.angebotssumme), 0);
|
||||||
|
const rahmen = n(contract.auftragssumme) + nachtragssumme;
|
||||||
|
|
||||||
|
const andere = await prisma.invoice.aggregate({
|
||||||
|
where: {
|
||||||
|
contractId: contract.id,
|
||||||
|
id: { not: invoice.id },
|
||||||
|
status: { notIn: ["ZURUECKGEWIESEN"] },
|
||||||
|
typ: { not: "GUTSCHRIFT" },
|
||||||
|
},
|
||||||
|
_sum: { anerkanntNetto: true, nettoBetrag: true },
|
||||||
|
});
|
||||||
|
const vorher = n(andere._sum.anerkanntNetto) || n(andere._sum.nettoBetrag);
|
||||||
|
const kumuliert = vorher + netto;
|
||||||
|
|
||||||
|
if (rahmen > 0 && kumuliert > rahmen + 0.01) {
|
||||||
|
befunde.push({
|
||||||
|
art: "SUMME_UEBER_AUFTRAG",
|
||||||
|
schwere: "KRITISCH",
|
||||||
|
meldung:
|
||||||
|
"Die kumulierte Abrechnung übersteigt die Auftragssumme inklusive beauftragter Nachträge. " +
|
||||||
|
"Vor Freigabe ist ein Nachtrag erforderlich.",
|
||||||
|
erwartet: eur(rahmen),
|
||||||
|
gefunden: eur(kumuliert),
|
||||||
|
differenz: kumuliert - rahmen,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Ergebnis speichern ───────────────────────────────────────────
|
||||||
|
const anerkanntNetto = Math.round(lineUpdates.reduce((s, l) => s + l.betragAnerkannt, 0) * 100) / 100;
|
||||||
|
const basis = invoice.zeilen.length > 0 ? summeZeilen : netto;
|
||||||
|
const kuerzung = Math.round(Math.max(0, basis - anerkanntNetto) * 100) / 100;
|
||||||
|
|
||||||
|
await prisma.$transaction([
|
||||||
|
prisma.invoiceFinding.deleteMany({ where: { invoiceId, erledigt: false } }),
|
||||||
|
...lineUpdates.map((l) =>
|
||||||
|
prisma.invoiceLine.update({
|
||||||
|
where: { id: l.id },
|
||||||
|
data: { mengeAnerkannt: l.mengeAnerkannt, betragAnerkannt: l.betragAnerkannt },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
prisma.invoiceFinding.createMany({
|
||||||
|
data: befunde.map((b) => ({
|
||||||
|
invoiceId,
|
||||||
|
lineId: b.lineId ?? null,
|
||||||
|
art: b.art,
|
||||||
|
schwere: b.schwere,
|
||||||
|
meldung: b.meldung,
|
||||||
|
erwartet: b.erwartet ?? null,
|
||||||
|
gefunden: b.gefunden ?? null,
|
||||||
|
differenz: b.differenz !== undefined ? Math.round(b.differenz * 100) / 100 : null,
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
prisma.invoice.update({
|
||||||
|
where: { id: invoiceId },
|
||||||
|
data: {
|
||||||
|
letzterPrueflauf: new Date(),
|
||||||
|
anerkanntNetto: invoice.zeilen.length > 0 ? anerkanntNetto : null,
|
||||||
|
kuerzungNetto: invoice.zeilen.length > 0 ? kuerzung : 0,
|
||||||
|
status: invoice.status === "EINGEGANGEN" ? "IN_PRUEFUNG" : invoice.status,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
befunde: befunde.length,
|
||||||
|
kritisch: befunde.filter((b) => b.schwere === "KRITISCH").length,
|
||||||
|
warnungen: befunde.filter((b) => b.schwere === "WARNUNG").length,
|
||||||
|
anerkanntNetto,
|
||||||
|
kuerzungNetto: kuerzung,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FINDING_LABEL: Record<FindingKind, string> = {
|
||||||
|
KEIN_VERTRAGSBEZUG: "Kein Vertragsbezug",
|
||||||
|
PREIS_ABWEICHUNG: "Preisabweichung",
|
||||||
|
MENGE_OHNE_AUFMASS: "Menge ohne Aufmaß",
|
||||||
|
MENGE_UEBER_AUFMASS: "Menge über Aufmaß",
|
||||||
|
STUNDEN_OHNE_ZETTEL: "Stunden ohne Zettel",
|
||||||
|
STUNDEN_UEBER_ZETTEL: "Stunden über Zettel",
|
||||||
|
STUNDENSATZ_ABWEICHUNG: "Stundensatz weicht ab",
|
||||||
|
ZETTEL_NICHT_ANERKANNT: "Zettel nicht bestätigt",
|
||||||
|
DOPPELABRECHNUNG: "Doppelabrechnung",
|
||||||
|
NACHTRAG_NICHT_BEAUFTRAGT: "Nachtrag nicht beauftragt",
|
||||||
|
RECHENFEHLER: "Rechenfehler",
|
||||||
|
SUMME_UEBER_AUFTRAG: "Auftragssumme überschritten",
|
||||||
|
ZUSCHLAG_UNBELEGT: "Zuschlag unbelegt",
|
||||||
|
HINWEIS: "Hinweis",
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { mkdir, writeFile, readFile, stat } from "fs/promises";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const ROOT = process.env.STORAGE_DIR || path.join(process.cwd(), "storage");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dateien liegen als gewöhnliche Dateien im Compose-Verzeichnis:
|
||||||
|
* storage/<projektnummer>/<jahr>/<hash-präfix>/<id>.<ext>
|
||||||
|
* Damit ist ein Backup ein Kopiervorgang und die Ablage bleibt auch ohne
|
||||||
|
* die Anwendung lesbar.
|
||||||
|
*/
|
||||||
|
export async function speichereDatei(
|
||||||
|
projektNummer: string,
|
||||||
|
dateiname: string,
|
||||||
|
daten: Buffer,
|
||||||
|
): Promise<{ pfad: string; sha256: string; groesse: number }> {
|
||||||
|
const sha256 = createHash("sha256").update(daten).digest("hex");
|
||||||
|
const jahr = String(new Date().getFullYear());
|
||||||
|
const ext = path.extname(dateiname).slice(0, 12) || "";
|
||||||
|
const safeProjekt = projektNummer.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||||
|
const relDir = path.join(safeProjekt, jahr, sha256.slice(0, 2));
|
||||||
|
const relPfad = path.join(relDir, `${sha256.slice(0, 32)}${ext}`);
|
||||||
|
const absDir = path.join(ROOT, relDir);
|
||||||
|
|
||||||
|
await mkdir(absDir, { recursive: true });
|
||||||
|
const abs = path.join(ROOT, relPfad);
|
||||||
|
try {
|
||||||
|
await stat(abs); // identische Datei bereits vorhanden – Deduplizierung
|
||||||
|
} catch {
|
||||||
|
await writeFile(abs, daten);
|
||||||
|
}
|
||||||
|
return { pfad: relPfad, sha256, groesse: daten.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leseDatei(relPfad: string): Promise<Buffer> {
|
||||||
|
const wurzel = path.resolve(ROOT);
|
||||||
|
const abs = path.resolve(wurzel, relPfad);
|
||||||
|
// Pfadausbruch (../) verhindern
|
||||||
|
if (abs !== wurzel && !abs.startsWith(wurzel + path.sep)) throw new Error("Ungültiger Pfad.");
|
||||||
|
return readFile(/*turbopackIgnore: true*/ abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxUploadBytes(): number {
|
||||||
|
return (Number(process.env.MAX_UPLOAD_MB) || 200) * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ERLAUBT = new Set([
|
||||||
|
"application/pdf", "image/jpeg", "image/png", "image/tiff", "image/webp", "image/heic",
|
||||||
|
"application/msword", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"application/vnd.ms-powerpoint", "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
"text/plain", "text/csv", "application/zip", "application/x-zip-compressed",
|
||||||
|
"application/acad", "image/vnd.dwg", "image/vnd.dxf", "application/octet-stream",
|
||||||
|
"application/xml", "text/xml", "application/json", "application/x-ifc", "model/ifc",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function mimeErlaubt(mime: string): boolean {
|
||||||
|
return ERLAUBT.has(mime) || mime.startsWith("image/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{ts,tsx}"],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Anlehnung an das Farbklima der Bahn-Infrastruktur, aber eigenständig
|
||||||
|
db: {
|
||||||
|
red: "#EC0016",
|
||||||
|
dark: "#131821",
|
||||||
|
slate: "#1D2635",
|
||||||
|
line: "#2B3648",
|
||||||
|
muted: "#8A94A6",
|
||||||
|
cyan: "#00A3B4",
|
||||||
|
green: "#1E9B5B",
|
||||||
|
amber: "#D4A017",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["ui-sans-serif", "system-ui", "Segoe UI", "Roboto", "Helvetica Neue", "Arial", "sans-serif"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
version: "2.4"
|
||||||
|
|
||||||
|
# BauDoc Bahn – Baustellen- und Dokumentenmanagement für Eisenbahninfrastruktur
|
||||||
|
#
|
||||||
|
# Bewusst OHNE named volumes: alle persistenten Daten liegen als Bind-Mount
|
||||||
|
# direkt neben dieser Datei unter ./data/. Damit ist ein Backup ein simples
|
||||||
|
# "tar czf backup.tgz data/" und ein Umzug ein Kopiervorgang.
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: baudoc-db
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-baudoc}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-baudoc}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-baudoc}
|
||||||
|
# Deutsche Sortierung/Volltext
|
||||||
|
POSTGRES_INITDB_ARGS: "--locale=C.UTF-8 --encoding=UTF8"
|
||||||
|
volumes:
|
||||||
|
- ./data/postgres:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5433}:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-baudoc} -d ${POSTGRES_DB:-baudoc}"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
mail:
|
||||||
|
# Fängt im Betrieb ohne echten Mailserver alle Benachrichtigungen ab.
|
||||||
|
# Web-UI: http://localhost:8025 – im Produktivbetrieb durch echtes SMTP ersetzen.
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
container_name: baudoc-mail
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MP_MAX_MESSAGES: 5000
|
||||||
|
MP_DATABASE: /data/mailpit.db
|
||||||
|
volumes:
|
||||||
|
- ./data/mail:/data
|
||||||
|
ports:
|
||||||
|
- "${MAIL_UI_PORT:-8025}:8025"
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ./app
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baudoc-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://${POSTGRES_USER:-baudoc}:${POSTGRES_PASSWORD:-baudoc}@db:5432/${POSTGRES_DB:-baudoc}?schema=public
|
||||||
|
AUTH_SECRET: ${AUTH_SECRET:-bitte-in-.env-aendern-mindestens-32-zeichen-lang}
|
||||||
|
APP_URL: ${APP_URL:-http://localhost:3400}
|
||||||
|
STORAGE_DIR: /app/storage
|
||||||
|
SMTP_HOST: ${SMTP_HOST:-mail}
|
||||||
|
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||||
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
|
SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
SMTP_FROM: ${SMTP_FROM:-BauDoc Bahn <noreply@baudoc.local>}
|
||||||
|
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||||
|
MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-200}
|
||||||
|
TZ: Europe/Berlin
|
||||||
|
volumes:
|
||||||
|
# Hochgeladene Dokumente – liegen als normale Dateien im Compose-Verzeichnis
|
||||||
|
- ./data/files:/app/storage
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-3400}:3000"
|
||||||
@@ -0,0 +1,865 @@
|
|||||||
|
<title>BauDoc Bahn – Lösungskonzept</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* ═══ Tokens: helle Fassung vollständig auf :root ═══════════════════ */
|
||||||
|
:root {
|
||||||
|
--papier: #F3F5F2; /* Planpapier, leicht kühl */
|
||||||
|
--blatt: #FFFFFF;
|
||||||
|
--blatt-tief: #EAEDE8;
|
||||||
|
--tinte: #141D1B; /* Zeichentusche, Grünstich */
|
||||||
|
--tinte-weich: #4C5B57;
|
||||||
|
--tinte-fein: #77857F;
|
||||||
|
--hairline: #CFD6CF;
|
||||||
|
--hairline-st: #A9B4AD;
|
||||||
|
--akzent: #106B5B; /* Kupfergrün, oxidierter Fahrdraht */
|
||||||
|
--akzent-flor: #106B5B14;
|
||||||
|
--kritisch: #A8322A; /* Hp 0 */
|
||||||
|
--kritisch-fl: #A8322A12;
|
||||||
|
--warnung: #9A6100;
|
||||||
|
--warnung-fl: #9A610012;
|
||||||
|
--gut: #2A6B45;
|
||||||
|
--gut-fl: #2A6B4512;
|
||||||
|
--raster: #106B5B0E;
|
||||||
|
|
||||||
|
--sans: "Helvetica Neue", Helvetica, Arial, system-ui, sans-serif;
|
||||||
|
--serif: Charter, "Iowan Old Style", Georgia, "Times New Roman", serif;
|
||||||
|
--mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) {
|
||||||
|
--papier: #0D1412;
|
||||||
|
--blatt: #141D1A;
|
||||||
|
--blatt-tief: #1B2622;
|
||||||
|
--tinte: #E4EBE7;
|
||||||
|
--tinte-weich: #A3B0AA;
|
||||||
|
--tinte-fein: #7B8A84;
|
||||||
|
--hairline: #26332E;
|
||||||
|
--hairline-st: #3A4A44;
|
||||||
|
--akzent: #4FC0A6;
|
||||||
|
--akzent-flor: #4FC0A61A;
|
||||||
|
--kritisch: #E8756A;
|
||||||
|
--kritisch-fl: #E8756A18;
|
||||||
|
--warnung: #D9A020;
|
||||||
|
--warnung-fl: #D9A02018;
|
||||||
|
--gut: #5FBF87;
|
||||||
|
--gut-fl: #5FBF8718;
|
||||||
|
--raster: #4FC0A60F;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-theme="dark"] {
|
||||||
|
--papier: #0D1412;
|
||||||
|
--blatt: #141D1A;
|
||||||
|
--blatt-tief: #1B2622;
|
||||||
|
--tinte: #E4EBE7;
|
||||||
|
--tinte-weich: #A3B0AA;
|
||||||
|
--tinte-fein: #7B8A84;
|
||||||
|
--hairline: #26332E;
|
||||||
|
--hairline-st: #3A4A44;
|
||||||
|
--akzent: #4FC0A6;
|
||||||
|
--akzent-flor: #4FC0A61A;
|
||||||
|
--kritisch: #E8756A;
|
||||||
|
--kritisch-fl: #E8756A18;
|
||||||
|
--warnung: #D9A020;
|
||||||
|
--warnung-fl: #D9A02018;
|
||||||
|
--gut: #5FBF87;
|
||||||
|
--gut-fl: #5FBF8718;
|
||||||
|
--raster: #4FC0A60F;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--papier);
|
||||||
|
color: var(--tinte);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1.65;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
.blatt {
|
||||||
|
max-width: 1180px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 clamp(18px, 4vw, 56px) 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Kopf: Planschriftfeld ════════════════════════════════════════ */
|
||||||
|
.kopf {
|
||||||
|
position: relative;
|
||||||
|
border-bottom: 1px solid var(--hairline-st);
|
||||||
|
padding: clamp(40px, 7vw, 84px) 0 34px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.kopf::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(var(--raster) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, var(--raster) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, #000 0%, transparent 78%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.kopf > * { position: relative; }
|
||||||
|
|
||||||
|
.marke {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.18em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--akzent);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 22px;
|
||||||
|
margin-bottom: clamp(24px, 5vw, 46px);
|
||||||
|
}
|
||||||
|
.marke span:not(:first-child) { color: var(--tinte-fein); }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: clamp(34px, 6.2vw, 66px);
|
||||||
|
line-height: 1.02;
|
||||||
|
letter-spacing: -0.028em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 22px;
|
||||||
|
max-width: 17ch;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.these {
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: clamp(17px, 2.1vw, 22px);
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--tinte-weich);
|
||||||
|
max-width: 60ch;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.these b { color: var(--tinte); font-weight: 600; }
|
||||||
|
|
||||||
|
/* Schriftfeld unten im Kopf */
|
||||||
|
.schriftfeld {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
margin-top: clamp(30px, 5vw, 52px);
|
||||||
|
background: var(--blatt);
|
||||||
|
}
|
||||||
|
.schriftfeld > div {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-right: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
.schriftfeld > div:last-child { border-right: 0; }
|
||||||
|
.schriftfeld dt {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.schriftfeld dd {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Abschnitte mit Gliederungsspalte ═════════════════════════════ */
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 108px minmax(0, 1fr);
|
||||||
|
gap: 0 34px;
|
||||||
|
padding: clamp(38px, 6vw, 66px) 0;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
section { grid-template-columns: 1fr; gap: 14px; }
|
||||||
|
}
|
||||||
|
.gliederung {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--akzent);
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 2px solid var(--akzent);
|
||||||
|
align-self: start;
|
||||||
|
}
|
||||||
|
.gliederung small {
|
||||||
|
display: block;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 10px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: clamp(23px, 3vw, 31px);
|
||||||
|
line-height: 1.16;
|
||||||
|
letter-spacing: -0.018em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
max-width: 24ch;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 30px 0 8px;
|
||||||
|
}
|
||||||
|
p { margin: 0 0 15px; max-width: 68ch; }
|
||||||
|
.inhalt > p:last-child { margin-bottom: 0; }
|
||||||
|
strong { font-weight: 650; }
|
||||||
|
a { color: var(--akzent); text-decoration-thickness: 1px; text-underline-offset: 2px; }
|
||||||
|
a:focus-visible, button:focus-visible {
|
||||||
|
outline: 2px solid var(--akzent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.86em;
|
||||||
|
background: var(--blatt-tief);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Kennzahlen ═══════════════════════════════════════════════════ */
|
||||||
|
.kennzahlen {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(158px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--hairline);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
margin: 26px 0;
|
||||||
|
}
|
||||||
|
.kennzahlen > div { background: var(--blatt); padding: 16px 18px; }
|
||||||
|
.kennzahlen .zahl {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.kennzahlen .bez {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.13em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
margin-top: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Tabellen ═════════════════════════════════════════════════════ */
|
||||||
|
.scroller { overflow-x: auto; margin: 24px 0; border: 1px solid var(--hairline); background: var(--blatt); }
|
||||||
|
table { border-collapse: collapse; width: 100%; min-width: 560px; font-size: 14px; }
|
||||||
|
th {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid var(--hairline-st);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-bottom: 1px solid var(--hairline);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.oz { font-family: var(--mono); font-size: 12px; color: var(--tinte-fein); white-space: nowrap; }
|
||||||
|
tfoot td {
|
||||||
|
border-top: 2px solid var(--hairline-st);
|
||||||
|
font-weight: 700;
|
||||||
|
background: var(--blatt-tief);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Marken ═══════════════════════════════════════════════════════ */
|
||||||
|
.marker {
|
||||||
|
display: inline-block;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border: 1px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.m-krit { color: var(--kritisch); background: var(--kritisch-fl); }
|
||||||
|
.m-warn { color: var(--warnung); background: var(--warnung-fl); }
|
||||||
|
.m-gut { color: var(--gut); background: var(--gut-fl); }
|
||||||
|
|
||||||
|
/* ═══ Belegkette ═══════════════════════════════════════════════════ */
|
||||||
|
.kette {
|
||||||
|
display: grid;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--hairline);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
margin: 26px 0;
|
||||||
|
}
|
||||||
|
.stufe {
|
||||||
|
background: var(--blatt);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 44px minmax(0, 1fr);
|
||||||
|
gap: 18px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.stufe .nr {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--akzent);
|
||||||
|
border: 1px solid var(--akzent);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.stufe h4 { margin: 4px 0 4px; font-size: 15px; font-weight: 700; }
|
||||||
|
.stufe p { margin: 0; font-size: 14px; color: var(--tinte-weich); max-width: 62ch; }
|
||||||
|
.stufe .rolle {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Modulliste ═══════════════════════════════════════════════════ */
|
||||||
|
.module {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(268px, 1fr));
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--hairline);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
margin: 26px 0;
|
||||||
|
}
|
||||||
|
.modul { background: var(--blatt); padding: 18px 20px; }
|
||||||
|
.modul .quelle {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--akzent);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.modul h4 { margin: 0 0 7px; font-size: 16px; font-weight: 700; }
|
||||||
|
.modul p { margin: 0; font-size: 14px; line-height: 1.55; color: var(--tinte-weich); }
|
||||||
|
|
||||||
|
/* ═══ Merksatz ═════════════════════════════════════════════════════ */
|
||||||
|
.merksatz {
|
||||||
|
border-left: 2px solid var(--akzent);
|
||||||
|
background: var(--akzent-flor);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin: 28px 0;
|
||||||
|
font-family: var(--serif);
|
||||||
|
font-size: clamp(17px, 2vw, 20px);
|
||||||
|
line-height: 1.45;
|
||||||
|
max-width: 56ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Fragenliste ══════════════════════════════════════════════════ */
|
||||||
|
ol.fragen { margin: 22px 0 0; padding: 0; list-style: none; counter-reset: f; }
|
||||||
|
ol.fragen li {
|
||||||
|
counter-increment: f;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 40px minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 0;
|
||||||
|
border-top: 1px solid var(--hairline);
|
||||||
|
max-width: 74ch;
|
||||||
|
}
|
||||||
|
ol.fragen li::before {
|
||||||
|
content: "F" counter(f, decimal-leading-zero);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--akzent);
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
ol.fragen strong { display: block; margin-bottom: 2px; }
|
||||||
|
ol.fragen span { font-size: 14px; color: var(--tinte-weich); }
|
||||||
|
|
||||||
|
ul.strich { margin: 14px 0; padding: 0; list-style: none; }
|
||||||
|
ul.strich li {
|
||||||
|
padding: 6px 0 6px 20px;
|
||||||
|
position: relative;
|
||||||
|
max-width: 68ch;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
ul.strich li::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0; top: 15px;
|
||||||
|
width: 10px; height: 1px;
|
||||||
|
background: var(--hairline-st);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 34px 0 0;
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--tinte-fein);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* { animation: none !important; transition: none !important; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="blatt">
|
||||||
|
|
||||||
|
<header class="kopf">
|
||||||
|
<div class="marke">
|
||||||
|
<span>BauDoc Bahn</span>
|
||||||
|
<span>Lösungskonzept</span>
|
||||||
|
<span>Stand 09.08.2026</span>
|
||||||
|
<span>Fassung 1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1>Aus einer Prüfliste wird ein Bauprojekt­management.</h1>
|
||||||
|
|
||||||
|
<p class="these">
|
||||||
|
Übergeben wurde eine Tabelle: die <b>EIU-Ablagestruktur</b> mit 1.106 Zeilen.
|
||||||
|
Was zunächst wie eine Anlage aussieht, ist tatsächlich die vollständige Spezifikation
|
||||||
|
dessen, was in einem Infrastrukturprojekt entstehen, geprüft, übergeben und aufbewahrt
|
||||||
|
werden muss. Genau darauf ist dieses System gebaut — und um die Rechnungsprüfung
|
||||||
|
ergänzt, die daraus ohnehin folgt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<dl class="schriftfeld">
|
||||||
|
<div><dt>Ordner im Katalog</dt><dd>159</dd></div>
|
||||||
|
<div><dt>Soll-Unterlagen</dt><dd>944</dd></div>
|
||||||
|
<div><dt>Akten</dt><dd>P · B · K</dd></div>
|
||||||
|
<div><dt>Zustand</dt><dd>lauffähig</dd></div>
|
||||||
|
</dl>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">0<small>Ausgangslage</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Was in der übergebenen Tabelle tatsächlich steht</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die Datei ist keine Ordnerliste. Jede der 944 Soll-Unterlagen trägt fünf Angaben,
|
||||||
|
die zusammen einen vollständigen Steuerungsdatensatz ergeben — und die in einer
|
||||||
|
Tabelle nicht arbeiten können, weil niemand sie fortschreibt.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="strich">
|
||||||
|
<li><strong>Gliederung</strong> — dreistufige Aktenstruktur: Planungsakte (P), Baudokumentation mit Bau- und Betreiberakte (B.I / B.II), Kaufmännische Akte (K).</li>
|
||||||
|
<li><strong>Fachbereichsrelevanz</strong> — 16 Spalten, getrennt nach Fahrweg und Personenbahnhöfen. Damit lässt sich der Katalog auf ein konkretes Projekt zusammenschneiden.</li>
|
||||||
|
<li><strong>Übergabezeitpunkt</strong> — zur Inbetriebnahme, drei bzw. sechs Monate danach, sechs Monate nach Bauende, zum Projektabschluss. Das ist eine Frist, sobald Projekttermine bekannt sind.</li>
|
||||||
|
<li><strong>Aufbewahrung</strong> — Nutzungsdauer der Anlage oder Jahre nach Bauschlussmeldung.</li>
|
||||||
|
<li><strong>Medium und Regelwerksbezug</strong> — Papierakte, digital, Mikrofiche; dazu die einschlägige Richtlinie oder Verwaltungsvorschrift.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="merksatz">
|
||||||
|
Die Tabelle beantwortet die Frage „Was fehlt noch?“ nur zu dem Zeitpunkt,
|
||||||
|
an dem jemand sie von Hand gepflegt hat. Das System beantwortet sie jederzeit
|
||||||
|
— je Projekt, je Fachbereich, je Verantwortlichem, mit Frist.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Für die mitgelieferte Beispielmaßnahme — sechs Fachbereiche, Fahrweg — bleiben von
|
||||||
|
944 Positionen <strong>675 einschlägige</strong> übrig. Die übrigen 269 werden nicht
|
||||||
|
ausgeblendet, sondern begründet als nicht zutreffend geführt. Das ist der Unterschied
|
||||||
|
zwischen „haben wir vergessen“ und „haben wir bewusst nicht“.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">1<small>Kern</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Der Dokumentationsstand als Cockpit</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Beim Anlegen eines Projekts werden Fachbereiche und Termine hinterlegt. Daraus erzeugt
|
||||||
|
das System den projektspezifischen Soll-Katalog und rechnet jeden Übergabezeitpunkt in
|
||||||
|
ein Datum um — es gilt jeweils die früheste erreichbare Frist, damit sich nicht alles
|
||||||
|
vor dem Projektabschluss staut.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Danach zeigt eine Ansicht, was die Excel nie zeigen konnte: Erfüllungsgrad je Akte,
|
||||||
|
überfällige Positionen, wer zuständig ist, und welche Datei zu welcher Sollposition
|
||||||
|
gehört. Der Export in die ursprüngliche Spaltenlogik bleibt möglich — wer die Liste
|
||||||
|
weiterhin als Tabelle braucht, bekommt sie auf Knopfdruck.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="kennzahlen">
|
||||||
|
<div><div class="zahl">675</div><div class="bez">Sollpositionen im Beispiel</div></div>
|
||||||
|
<div><div class="zahl">3</div><div class="bez">Akten mit eigener Quote</div></div>
|
||||||
|
<div><div class="zahl">6</div><div class="bez">Fristanker aus den Terminen</div></div>
|
||||||
|
<div><div class="zahl">1</div><div class="bez">Klick zum CSV-Rückexport</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">2<small>Umfang</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Module — und woher sie kommen</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Kein Modul ist erfunden. Jedes bildet etwas ab, das in der Ablagestruktur als
|
||||||
|
aufzubewahrende Unterlage benannt ist oder im Vorgabeprozess Bauüberwachung als
|
||||||
|
Pflicht der Bauüberwachung steht.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="module">
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">Ablage B · K</span>
|
||||||
|
<h4>Dokumentenmanagement</h4>
|
||||||
|
<p>Versionen, Prüf- und Freigabelauf, Vertraulichkeitsstufen, Suche über Metadaten und Schlagworte. Jede Datei mit SHA-256-Prüfsumme, jeder Abruf protokolliert.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">B.I 2.2 · Ril 809.0301</span>
|
||||||
|
<h4>Bautagebuch</h4>
|
||||||
|
<p>Wetter, Arbeitszeit, Betra und Sperrpause, Personal- und Gerätestunden, besondere Vorkommnisse. Nach Abschluss gesperrt — Änderungen nur als Nachtrag.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">B.I 2.5</span>
|
||||||
|
<h4>Meldungen und Mängel</h4>
|
||||||
|
<p>Behinderungs-, Bedenken- und Mängelanzeige, Mehrkosten und Anordnung von Leistungen, Unfall- und Sofortmeldung — mit Frist, Zuständigkeit und Verlauf.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">K 1 · K 2</span>
|
||||||
|
<h4>Verträge und Nachträge</h4>
|
||||||
|
<p>Leistungsverzeichnis, Nachtragsstatus mit Stellungnahme der Bauüberwachung, Auftragsvolumen inklusive beauftragter Nachträge.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">K 3.3 · K 3.4</span>
|
||||||
|
<h4>Aufmaß und Stundenzettel</h4>
|
||||||
|
<p>Der Auftragnehmer reicht ein, die Bauüberwachung erkennt zeilenweise an. Diese anerkannte Menge ist die verbindliche Obergrenze für alles Folgende.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">K 3</span>
|
||||||
|
<h4>Rechnungsprüfung</h4>
|
||||||
|
<p>Automatischer Abgleich gegen Vertrag, Aufmaß, Stundenzettel und Vorrechnungen. Getrennte Bescheinigung sachlich und rechnerisch richtig.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">B.I 2.4</span>
|
||||||
|
<h4>Kommunikation</h4>
|
||||||
|
<p>Projektkanäle statt E-Mail-Verteiler. Erwähnungen benachrichtigen gezielt; wer weiterhin per E-Mail arbeiten möchte, stellt das im Profil ein.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modul">
|
||||||
|
<span class="quelle">B.I 0 · Vorgabeprozess</span>
|
||||||
|
<h4>Beteiligte und Rechte</h4>
|
||||||
|
<p>Rollen nach dem Vorgabeprozess: PL, BHV, BÜB, FBÜ, kaufmännische Steuerung, Planung, Auftragnehmer, Prüfer, Betreiber.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">3<small>Kernfall</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Rechnungsprüfung: eine echte Abschlagsrechnung, durchgerechnet</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Der Wunsch war, Rechnungen der Baufirmen gegen die Stundenzettel zu halten. Das ist
|
||||||
|
möglich — und es lohnt sich, an derselben Stelle auch die Mengen mitzuprüfen, weil die
|
||||||
|
Belege dafür ohnehin im System liegen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Die Belegkette</h3>
|
||||||
|
<div class="kette">
|
||||||
|
<div class="stufe">
|
||||||
|
<div class="nr">1</div>
|
||||||
|
<div>
|
||||||
|
<h4>Leistung wird belegt</h4>
|
||||||
|
<p>Der Auftragnehmer reicht Aufmaß oder Stundenlohnzettel ein. Aus dem Bautagebuch ist unabhängig davon bekannt, wer an dem Tag mit wie vielen Leuten und Geräten vor Ort war.</p>
|
||||||
|
<span class="rolle">Auftragnehmer</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stufe">
|
||||||
|
<div class="nr">2</div>
|
||||||
|
<div>
|
||||||
|
<h4>Beleg wird zeilenweise anerkannt</h4>
|
||||||
|
<p>Die Bauüberwachung trägt je Zeile die anerkannte Menge oder Stundenzahl ein, mit Prüfvermerk. Beispiel aus der Demo: gemeldet 9 Stunden je Mann, anerkannt 7 — An- und Abfahrt sind mit den Einheitspreisen abgegolten.</p>
|
||||||
|
<span class="rolle">Bauüberwacher Bahn</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stufe">
|
||||||
|
<div class="nr">3</div>
|
||||||
|
<div>
|
||||||
|
<h4>Rechnung wird automatisch abgeglichen</h4>
|
||||||
|
<p>Jede Position gegen Vertragspreis, anerkanntes Aufmaß, bestätigten Zettel, beauftragten Nachtrag und bereits abgerechnete Mengen. Ergebnis ist ein Prüfbericht mit vorgeschlagener Kürzung.</p>
|
||||||
|
<span class="rolle">System</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stufe">
|
||||||
|
<div class="nr">4</div>
|
||||||
|
<div>
|
||||||
|
<h4>Zwei Personen entscheiden</h4>
|
||||||
|
<p>Sachlich richtig durch die Bauüberwachung, rechnerisch richtig durch die kaufmännische Steuerung, danach die Zahlungsfreigabe. Beide Bescheinigungen sind technisch nicht durch dieselbe Person möglich.</p>
|
||||||
|
<span class="rolle">BÜB → Kaufmännische Steuerung → PL / BHV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Prüfbericht zur Rechnung 2026-AR-0431</h3>
|
||||||
|
<p>
|
||||||
|
Abschlagsrechnung der Nordbahn Gleisbau GmbH über 2.166.283,00 EUR netto. Die
|
||||||
|
Rechnung ist absichtlich mit den Fehlern versehen, die in der Praxis vorkommen —
|
||||||
|
das System findet sie ohne Zutun.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="scroller">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Pos.</th>
|
||||||
|
<th>OZ / Beleg</th>
|
||||||
|
<th>Befund</th>
|
||||||
|
<th>Beleg sagt</th>
|
||||||
|
<th>Rechnung sagt</th>
|
||||||
|
<th class="num">Differenz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">3</td>
|
||||||
|
<td class="oz">01.01.0020</td>
|
||||||
|
<td><span class="marker m-krit">Preisabweichung</span><br>Einheitspreis über Vertrag</td>
|
||||||
|
<td class="num">24,90 EUR/m²</td>
|
||||||
|
<td class="num">26,40 EUR/m²</td>
|
||||||
|
<td class="num">12.690,00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">4</td>
|
||||||
|
<td class="oz">01.02.0040</td>
|
||||||
|
<td><span class="marker m-krit">Menge über Aufmaß</span><br>drei Weichen abgerechnet, zwei anerkannt</td>
|
||||||
|
<td class="num">2 St</td>
|
||||||
|
<td class="num">3 St</td>
|
||||||
|
<td class="num">148.500,00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">5</td>
|
||||||
|
<td class="oz">02.01.0030</td>
|
||||||
|
<td><span class="marker m-krit">Menge ohne Aufmaß</span><br>Überbau liegt noch auf dem Lagerplatz</td>
|
||||||
|
<td class="num">0 psch</td>
|
||||||
|
<td class="num">1 psch</td>
|
||||||
|
<td class="num">384.000,00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">7</td>
|
||||||
|
<td class="oz">SLZ-2026-091</td>
|
||||||
|
<td><span class="marker m-krit">Stunden über Zettel</span><br>gemeldete statt anerkannter Stunden</td>
|
||||||
|
<td class="num">21,00 Std.</td>
|
||||||
|
<td class="num">25,00 Std.</td>
|
||||||
|
<td class="num">313,60</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">7</td>
|
||||||
|
<td class="oz">SLZ-2026-091</td>
|
||||||
|
<td><span class="marker m-warn">Stundensatz weicht ab</span><br>zur Klärung, nicht zur Kürzung</td>
|
||||||
|
<td class="num">92,75 EUR/Std.</td>
|
||||||
|
<td class="num">78,40 EUR/Std.</td>
|
||||||
|
<td class="num">—</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">8</td>
|
||||||
|
<td class="oz">SLZ-2026-097</td>
|
||||||
|
<td><span class="marker m-krit">Zettel nicht bestätigt</span><br>noch nicht durch die Bauüberwachung geprüft</td>
|
||||||
|
<td class="num">eingereicht</td>
|
||||||
|
<td class="num">abgerechnet</td>
|
||||||
|
<td class="num">627,00</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="oz">10</td>
|
||||||
|
<td class="oz">03.01.0010</td>
|
||||||
|
<td><span class="marker m-krit">Menge über Aufmaß</span><br>Kabeltrog nur teilweise anerkannt</td>
|
||||||
|
<td class="num">1.650 m</td>
|
||||||
|
<td class="num">1.800 m</td>
|
||||||
|
<td class="num">9.360,00</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">Vorgeschlagene Kürzung netto — anerkannt 1.610.792,40 EUR</td>
|
||||||
|
<td class="num">555.490,60</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Position 9 — die Nachtragsleistung Bodenaustausch über 186.400 EUR — wird
|
||||||
|
<span class="marker m-gut">ohne Befund</span> durchgelassen, weil der Nachtrag N-01
|
||||||
|
geprüft und beauftragt ist. Das ist ebenso wichtig wie das Finden von Fehlern: Wer
|
||||||
|
alles beanstandet, wird nicht mehr gelesen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3>Was sonst noch geprüft wird</h3>
|
||||||
|
<ul class="strich">
|
||||||
|
<li>Doppelabrechnung desselben Stundenlohnzettels über mehrere Rechnungen hinweg</li>
|
||||||
|
<li>Nachtragsleistung ohne beauftragten Nachtrag</li>
|
||||||
|
<li>Zuschlag in der Rechnung, der auf dem Zettel nicht vermerkt ist</li>
|
||||||
|
<li>Rechenfehler in Zeile, Umsatzsteuer und Bruttobetrag</li>
|
||||||
|
<li>kumulierte Abrechnung über der Auftragssumme einschließlich Nachträge</li>
|
||||||
|
<li>Hinweis bei Mengenmehrung über 110 Prozent — Preisanpassung nach § 2 Abs. 3 VOB/B prüfen</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="merksatz">
|
||||||
|
Das System entscheidet nichts. Es legt die Belege nebeneinander und benennt jede
|
||||||
|
Abweichung mit Betrag. Die Bescheinigung bleibt eine persönliche Erklärung —
|
||||||
|
auch die bewusste Freigabe trotz Befund, die mit Begründung protokolliert wird.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">4<small>Rechte</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Wer darf was</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Rollen werden je Projekt vergeben, bei Fachbauüberwachern zusätzlich je Fachbereich.
|
||||||
|
Auftragnehmer sehen ausschließlich eigene Verträge, Stunden, Aufmaße und Rechnungen
|
||||||
|
sowie freigegebene, nicht als intern gekennzeichnete Unterlagen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="scroller">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Rolle</th>
|
||||||
|
<th>Dokumente freigeben</th>
|
||||||
|
<th>Bautagebuch</th>
|
||||||
|
<th>Stunden prüfen</th>
|
||||||
|
<th>Sachlich richtig</th>
|
||||||
|
<th>Rechnerisch richtig</th>
|
||||||
|
<th>Zahlung freigeben</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>Projektleitung (PL)</td><td>ja</td><td>gegenzeichnen</td><td>—</td><td>—</td><td>—</td><td>ja</td></tr>
|
||||||
|
<tr><td>Bauherrenvertretung (BHV)</td><td>ja</td><td>gegenzeichnen</td><td>—</td><td>—</td><td>—</td><td>ja</td></tr>
|
||||||
|
<tr><td>Bauüberwacher Bahn (BÜB)</td><td>ja</td><td>führen</td><td>ja</td><td>ja</td><td>—</td><td>—</td></tr>
|
||||||
|
<tr><td>Fachbauüberwacher (FBÜ)</td><td>im Fachbereich</td><td>führen</td><td>ja</td><td>—</td><td>—</td><td>—</td></tr>
|
||||||
|
<tr><td>Kaufmännische Steuerung</td><td>—</td><td>lesen</td><td>—</td><td>—</td><td>ja</td><td>—</td></tr>
|
||||||
|
<tr><td>Auftragnehmer</td><td>—</td><td>—</td><td>einreichen</td><td>—</td><td>—</td><td>—</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Sämtliche Vorgänge landen in einem Protokoll: Anmeldung, Upload, Abruf einer Datei,
|
||||||
|
Freigabe, Prüflauf, Bescheinigung. Damit ist auch Jahre später nachvollziehbar,
|
||||||
|
wer wann was erklärt hat.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">5<small>Betrieb</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Technik, bewusst schmal gehalten</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Vier bewegliche Teile, keine Fremddienste, keine Cloud-Abhängigkeit. Das ganze System
|
||||||
|
startet mit einem Befehl und läuft ebenso im eigenen Rechenzentrum wie auf einem
|
||||||
|
Bauleitungsserver.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ul class="strich">
|
||||||
|
<li><strong>Anwendung</strong> — ein Container, Oberfläche und Server-Logik in einem Prozess</li>
|
||||||
|
<li><strong>PostgreSQL</strong> — ein Container für die Datenhaltung</li>
|
||||||
|
<li><strong>E-Mail</strong> — beliebiges SMTP-Relais des Hauses; für den Test ein mitgelieferter Abfangserver</li>
|
||||||
|
<li><strong>Anmeldung</strong> — im System selbst, ohne zusätzlichen Dienst; Anbindung an das Unternehmensverzeichnis ist nachrüstbar</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>Dateien bleiben Dateien</h3>
|
||||||
|
<p>
|
||||||
|
Alles Persistente liegt als gewöhnliches Verzeichnis neben der Konfiguration —
|
||||||
|
Datenbank unter <code>data/postgres</code>, Dokumente unter <code>data/files</code>,
|
||||||
|
abgelegt nach Projektnummer und Jahr. Ein Backup ist ein Archiv über einen Ordner,
|
||||||
|
ein Umzug ein Kopiervorgang. Die Ablage ist auch dann noch lesbar, wenn die Anwendung
|
||||||
|
eines Tages nicht mehr läuft — bei einer Aufbewahrungspflicht von bis zu 38 Jahren
|
||||||
|
nach Bauschlussmeldung ist das keine Nebensache.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════════════ -->
|
||||||
|
<section>
|
||||||
|
<div class="gliederung">6<small>Offen</small></div>
|
||||||
|
<div class="inhalt">
|
||||||
|
<h2>Was bewusst offen geblieben ist</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Die folgenden Punkte sind nicht vergessen, sondern absichtlich zurückgestellt: Sie
|
||||||
|
setzen Entscheidungen voraus, die nur der Auftraggeber treffen kann. Sie sind der
|
||||||
|
eigentliche Zweck des nächsten Gesprächs.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ol class="fragen">
|
||||||
|
<li>
|
||||||
|
<strong>Verhältnis zum elektronischen Bautagebuch (eBTB)</strong>
|
||||||
|
<span>Wird das Bautagebuch hier geführt oder aus dem bestehenden System übernommen? Beides ist möglich, aber es sollte nur eine führende Quelle geben.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Rechnungseingang</strong>
|
||||||
|
<span>Sollen Rechnungen als X-Rechnung oder ZUGFeRD eingelesen werden statt erfasst? Das ändert nichts an der Prüflogik, spart aber die Erfassung.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Leistungsverzeichnisse</strong>
|
||||||
|
<span>Liegen die LV als GAEB-Datei vor? Dann entfällt das Anlegen der Positionen von Hand.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Anmeldung</strong>
|
||||||
|
<span>Eigene Benutzerverwaltung oder Anmeldung über das Unternehmensverzeichnis — und wie kommen externe Baufirmen hinein?</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Erfassung vor Ort</strong>
|
||||||
|
<span>Wird auf der Baustelle mit dem Telefon gearbeitet? Dann sind Fotodokumentation mit Ortsbezug und eine Offline-Erfassung sinnvoll.</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Aufbewahrung und Aussonderung</strong>
|
||||||
|
<span>Was geschieht nach Ablauf der Frist — automatischer Hinweis, Export in ein Archivsystem, oder Löschung nach Freigabe?</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="merksatz">
|
||||||
|
Vorschlag für das nächste Gespräch: nicht über Anforderungen sprechen, sondern
|
||||||
|
gemeinsam die Beispielrechnung durchgehen. Erfahrungsgemäß fällt dabei innerhalb
|
||||||
|
von zehn Minuten auf, was im eigenen Haus anders läuft — und genau das ist die
|
||||||
|
Anforderungsliste.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<span>BauDoc Bahn</span>
|
||||||
|
<span>Lösungskonzept · Fassung 1</span>
|
||||||
|
<span>Grundlage: 2-EIU-Ablagestruktur.xlsx</span>
|
||||||
|
<span>Stand 09.08.2026</span>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user