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:
duffyduck
2026-08-11 13:05:09 +02:00
co-authored by Claude Opus 5
commit 807b7ce541
82 changed files with 45684 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
.next
storage
.env
*.log
+37
View File
@@ -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"]
+28
View File
@@ -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 "$@"
+11
View File
@@ -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;
+2830
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -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"
}
}
+3
View File
@@ -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
+656
View File
@@ -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 16, 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>
);
}
+82
View File
@@ -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>
);
}
+148
View File
@@ -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>
);
}
+319
View File
@@ -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>
);
}
+138
View File
@@ -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}`);
}
+71
View File
@@ -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>
);
}
+133
View File
@@ -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>
);
}
+378
View File
@@ -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>
);
}
+147
View File
@@ -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>
);
}
+86
View File
@@ -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"`,
},
});
}
+60
View File
@@ -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; }
+15
View File
@@ -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>
);
}
+7
View File
@@ -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");
}
+43
View File
@@ -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>
);
}
+118
View File
@@ -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>
);
}
+146
View File
@@ -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),
};
}
+11
View File
@@ -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;
+44
View File
@@ -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(" ");
}
+100
View File
@@ -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)",
];
+62
View File
@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string,
);
}
+102
View File
@@ -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,
},
});
}
+115
View File
@@ -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)),
};
}
+237
View File
@@ -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}".`);
}
}
+506
View File
@@ -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",
};
+61
View File
@@ -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/");
}
+26
View File
@@ -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;
+41
View File
@@ -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"
]
}