Benutzerverwaltung, Firmenstamm und Rollenlegende

Die Verwaltung war bisher eine reine Anzeige. Sie gliedert sich jetzt in vier
Reiter und ist handlungsfähig.

Benutzer
- Zugänge anlegen, bearbeiten, sperren und entsperren
- Startkennwort wird erzeugt und genau einmal angezeigt, auf Wunsch zusätzlich
  per E-Mail versandt; es liegt nirgends im Klartext
- Kennwort zurücksetzen beendet zugleich alle laufenden Sitzungen
- Wer sich mit einem Startkennwort anmeldet, wird unmittelbar zur Vergabe eines
  eigenen Kennworts geführt; bis dahin weist ein Streifen im Kopfbereich darauf hin
- Zugänge werden gesperrt, nicht gelöscht: Bautagebucheinträge, Prüfvermerke und
  Bescheinigungen müssen dauerhaft einer Person zuordenbar bleiben

Aussperrschutz
- der eigene Zugang lässt sich weder sperren noch die eigene Administration entziehen
- es muss stets mindestens ein aktiver Administrationszugang bestehen
- Firmen mit bestehenden Verträgen lassen sich nicht stilllegen

Firmen
- Stammdaten mit Art, Anschrift, Kreditorennummer und USt-IdNr., stilllegbar

Rollen und Rechte
- Legende aller Rollen, getrennt nach Auftraggeber- und Auftragnehmerseite
- vollständige Rechtematrix, unmittelbar aus PERMISSIONS erzeugt und damit nicht
  von der Wirklichkeit im Code trennbar
- Übersicht der Projektschalter und der fest verdrahteten Sperren

Protokoll
- Filter nach Aktion und Person, Blätterung, haftungsrelevante Vorgänge hervorgehoben

Nebenbei behoben
- next.config: "output: standalone" entfernt, weil es sich mit "next start" nicht
  verträgt; nicht mehr unterstützten eslint-Schlüssel entfernt
- Unterlagen des Auftraggebers (*.xlsx) gehören nicht ins Repository

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
duffyduck
2026-08-11 13:22:42 +02:00
co-authored by Claude Opus 5
parent 807b7ce541
commit 8af6b384ea
15 changed files with 1495 additions and 139 deletions
+2 -3
View File
@@ -1,11 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone",
// Kein "output: standalone": das Laufzeit-Image bringt node_modules mit und
// startet über "next start". Beides zusammen verträgt sich nicht.
experimental: {
serverActions: { bodySizeLimit: "250mb" },
},
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: false },
};
export default nextConfig;
+13
View File
@@ -76,6 +76,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</div>
</header>
{user.mustChangePw && (
<div className="border-b border-amber-800/60 bg-amber-950/30">
<div className="mx-auto flex max-w-[1600px] items-center gap-3 px-5 py-2 text-sm">
<span className="text-amber-300">
Ihr Zugang läuft noch mit dem Startkennwort der Administration.
</span>
<Link href="/profil" className="font-medium text-amber-200 underline hover:text-amber-100">
Jetzt eigenes Kennwort vergeben
</Link>
</div>
</div>
)}
<main className="mx-auto max-w-[1600px] px-5 py-6">{children}</main>
</div>
);
+15 -1
View File
@@ -6,7 +6,11 @@ import { datumZeit } from "@/lib/format";
export const dynamic = "force-dynamic";
export default async function Profil({ searchParams }: { searchParams: Promise<{ ok?: string; fehler?: string }> }) {
export default async function Profil({
searchParams,
}: {
searchParams: Promise<{ ok?: string; fehler?: string; kennwort?: string }>;
}) {
const session = await requireUser();
const sp = await searchParams;
@@ -61,6 +65,16 @@ export default async function Profil({ searchParams }: { searchParams: Promise<{
</p>
</div>
{(sp.kennwort === "erforderlich" || user.mustChangePw) && (
<div className="rounded-md border border-amber-800/60 bg-amber-950/20 px-4 py-3">
<p className="text-sm font-medium text-amber-300">Bitte vergeben Sie ein eigenes Kennwort.</p>
<p className="mt-0.5 text-xs leading-relaxed text-amber-200/70">
Ihr Zugang wurde mit einem Startkennwort der Administration eingerichtet. Dieses ist der
Administration bekannt gewesen und sollte nicht dauerhaft verwendet werden.
</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)."}
@@ -0,0 +1,342 @@
"use client";
import { Fragment, useState, useTransition } from "react";
import { Check, Copy, KeyRound, Lock, Pencil, Plus, Unlock, UserPlus, X } from "lucide-react";
import { benutzerAnlegen, benutzerBearbeiten, benutzerStatus, kennwortZuruecksetzen } from "./actions";
import { StatusChip } from "@/components/ui";
import { datumZeit } from "@/lib/format";
type Benutzer = {
id: string;
email: string;
name: string;
position: string | null;
phone: string | null;
active: boolean;
isAdmin: boolean;
mustChangePw: boolean;
companyId: string | null;
firma: string | null;
lastLoginAt: string | null;
sitzungen: number;
rollen: string[];
projekte: number;
};
type Firma = { id: string; name: string; typ: string };
export function Benutzerliste({
eigeneId,
benutzer,
firmen,
}: {
eigeneId: string;
benutzer: Benutzer[];
firmen: Firma[];
}) {
const [neu, setNeu] = useState(false);
const [bearbeitet, setBearbeitet] = useState<string | null>(null);
const [suche, setSuche] = useState("");
const [zeigeGesperrte, setZeigeGesperrte] = useState(true);
const [fehler, setFehler] = useState<string | null>(null);
const [zugang, setZugang] = useState<{ name: string; email: string; kennwort: string } | null>(null);
const [pending, start] = useTransition();
const lauf = (fn: () => Promise<unknown>) =>
start(async () => {
setFehler(null);
try {
await fn();
} catch (e) {
setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich.");
}
});
const q = suche.trim().toLowerCase();
const gefiltert = benutzer.filter(
(b) =>
(zeigeGesperrte || b.active) &&
(!q ||
b.name.toLowerCase().includes(q) ||
b.email.toLowerCase().includes(q) ||
(b.firma ?? "").toLowerCase().includes(q) ||
(b.position ?? "").toLowerCase().includes(q)),
);
return (
<div className="space-y-4">
{/* ── Ausgabe eines neuen Kennworts ────────────────────── */}
{zugang && (
<div className="card border-emerald-800/60 bg-emerald-950/15 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold text-emerald-300">
Startkennwort für {zugang.name}
</h3>
<p className="mt-1 max-w-2xl text-xs leading-relaxed text-emerald-200/70">
Es wird nur dieses eine Mal angezeigt und ist nirgends im Klartext gespeichert.
Beim ersten Anmelden muss ein eigenes Kennwort vergeben werden.
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<code className="select-all rounded border border-emerald-800/60 bg-db-dark px-3 py-2 font-mono text-base tracking-wide text-emerald-200">
{zugang.kennwort}
</code>
<button
onClick={() => navigator.clipboard?.writeText(zugang.kennwort)}
className="btn-ghost btn-sm"
>
<Copy size={13} /> kopieren
</button>
<span className="text-xs text-emerald-200/60">für {zugang.email}</span>
</div>
</div>
<button onClick={() => setZugang(null)} className="text-emerald-300/60 hover:text-emerald-200">
<X size={17} />
</button>
</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>
)}
{/* ── Kopfzeile ────────────────────────────────────────── */}
<div className="card flex flex-wrap items-center gap-2 p-3">
<input
value={suche}
onChange={(e) => setSuche(e.target.value)}
placeholder="Name, E-Mail, Firma oder Funktion …"
className="input w-80 py-1.5 text-xs"
/>
<label className="flex items-center gap-1.5 text-xs text-slate-300">
<input type="checkbox" checked={zeigeGesperrte} onChange={(e) => setZeigeGesperrte(e.target.checked)} />
gesperrte Zugänge anzeigen
</label>
<span className="text-xs text-db-muted">{gefiltert.length} von {benutzer.length}</span>
<button onClick={() => { setNeu(!neu); setBearbeitet(null); }} className="btn-primary ml-auto">
<UserPlus size={15} /> Zugang anlegen
</button>
</div>
{/* ── Neuanlage ────────────────────────────────────────── */}
{neu && (
<form
className="card space-y-3 p-4"
action={(fd) =>
lauf(async () => {
const r = await benutzerAnlegen(fd);
setZugang({ name: r.name, email: r.email, kennwort: r.kennwort });
setNeu(false);
})
}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Neuen Zugang anlegen</h2>
<button type="button" onClick={() => setNeu(false)} className="text-db-muted hover:text-white">
<X size={17} />
</button>
</div>
<Felder firmen={firmen} />
<label className="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" name="mailSenden" defaultChecked />
Zugangsdaten zusätzlich per E-Mail versenden
</label>
<button type="submit" disabled={pending} className="btn-primary">
<Plus size={14} /> {pending ? "wird angelegt …" : "Anlegen"}
</button>
</form>
)}
{/* ── Liste ────────────────────────────────────────────── */}
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Person</th>
<th className="th">Firma</th>
<th className="th">Projektrollen</th>
<th className="th w-40">Letzte Anmeldung</th>
<th className="th w-64 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{gefiltert.map((b) => (
<Fragment key={b.id}>
<tr className={`row ${!b.active ? "opacity-50" : ""}`}>
<td className="td">
<div className="flex flex-wrap items-center gap-2">
<span className="text-slate-100">{b.name}</span>
{b.isAdmin && <span className="chip border-db-red/50 bg-db-red/15 text-red-300">Administration</span>}
{!b.active && <StatusChip status="ENTFAELLT" text="gesperrt" />}
{b.mustChangePw && b.active && (
<span className="chip border-amber-700 bg-amber-900/25 text-amber-300">
Kennwortwechsel offen
</span>
)}
{b.id === eigeneId && <span className="text-[11px] text-db-muted">(Sie)</span>}
</div>
<div className="text-[11px] text-db-muted">
{b.email}
{b.position && ` · ${b.position}`}
{b.phone && ` · ${b.phone}`}
</div>
</td>
<td className="td text-xs text-slate-400">{b.firma ?? "—"}</td>
<td className="td text-xs text-slate-400">
{b.rollen.length ? b.rollen.join(", ") : <span className="text-db-muted">keine</span>}
{b.projekte > 0 && (
<div className="text-[10px] text-db-muted">
in {b.projekte} {b.projekte === 1 ? "Projekt" : "Projekten"}
</div>
)}
</td>
<td className="td text-xs tabular-nums text-db-muted">
{b.lastLoginAt ? datumZeit(b.lastLoginAt) : "noch nie"}
{b.sitzungen > 0 && <div className="text-[10px]">{b.sitzungen} Sitzung(en)</div>}
</td>
<td className="td">
<div className="flex flex-wrap items-center justify-end gap-1.5">
<button
onClick={() => { setBearbeitet(bearbeitet === b.id ? null : b.id); setNeu(false); }}
className="btn-ghost btn-sm"
>
<Pencil size={12} /> bearbeiten
</button>
<button
disabled={pending}
onClick={() => {
if (!confirm(`Kennwort für ${b.name} zurücksetzen? Laufende Sitzungen werden beendet.`)) return;
const perMail = confirm("Zugangsdaten zusätzlich per E-Mail versenden?");
lauf(async () => {
const r = await kennwortZuruecksetzen(b.id, perMail);
setZugang(r);
});
}}
className="btn-ghost btn-sm"
title="Neues Startkennwort erzeugen"
>
<KeyRound size={12} />
</button>
{b.id !== eigeneId && (
<button
disabled={pending}
onClick={() => {
if (b.active && !confirm(`Zugang von ${b.name} sperren? Laufende Sitzungen werden beendet.`)) return;
lauf(() => benutzerStatus(b.id, !b.active));
}}
className="btn-ghost btn-sm"
title={b.active ? "Zugang sperren" : "Zugang entsperren"}
>
{b.active ? <Lock size={12} /> : <Unlock size={12} className="text-emerald-400" />}
</button>
)}
</div>
</td>
</tr>
{bearbeitet === b.id && (
<tr className="border-t border-db-line bg-db-dark/40">
<td className="td" colSpan={5}>
<form
className="space-y-3 p-1"
action={(fd) =>
lauf(async () => {
await benutzerBearbeiten(fd);
setBearbeitet(null);
})
}
>
<input type="hidden" name="userId" value={b.id} />
<Felder firmen={firmen} werte={b} eigenerZugang={b.id === eigeneId} />
<div className="flex gap-2">
<button type="submit" disabled={pending} className="btn-primary btn-sm">
<Check size={13} /> Speichern
</button>
<button type="button" onClick={() => setBearbeitet(null)} className="btn-ghost btn-sm">
Abbrechen
</button>
</div>
</form>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
<p className="text-xs leading-relaxed text-db-muted">
Zugänge werden gesperrt, nicht gelöscht. Bautagebucheinträge, Prüfvermerke und die
Bescheinigungen „sachlich richtig“ und „rechnerisch richtig“ müssen auch nach Jahren
einer Person zuordenbar bleiben – bei Aufbewahrungsfristen von bis zu 38 Jahren nach
Bauschlussmeldung ist das keine Nebensache.
</p>
</div>
);
}
function Felder({
firmen,
werte,
eigenerZugang = false,
}: {
firmen: Firma[];
werte?: Benutzer;
eigenerZugang?: boolean;
}) {
return (
<div className="grid gap-3 sm:grid-cols-3">
<div>
<label className="label mb-1.5 block">Name</label>
<input name="name" required defaultValue={werte?.name ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">E-Mail (Anmeldename)</label>
<input name="email" type="email" required defaultValue={werte?.email ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Telefon</label>
<input name="phone" defaultValue={werte?.phone ?? ""} className="input" />
</div>
<div className="sm:col-span-2">
<label className="label mb-1.5 block">Funktion</label>
<input
name="position"
defaultValue={werte?.position ?? ""}
placeholder="z. B. Bauüberwacher Bahn – Oberbau/KIB"
className="input"
/>
</div>
<div>
<label className="label mb-1.5 block">Firma</label>
<select name="companyId" defaultValue={werte?.companyId ?? ""} className="input">
<option value="">— ohne Zuordnung —</option>
{firmen.map((f) => (
<option key={f.id} value={f.id}>
{f.name}
</option>
))}
</select>
</div>
<div className="sm:col-span-3">
<label className={`flex items-start gap-2 text-sm ${eigenerZugang ? "opacity-60" : ""}`}>
<input type="checkbox" name="isAdmin" defaultChecked={werte?.isAdmin ?? false} className="mt-1" />
<span>
<span className="block text-slate-200">Administrationsrechte</span>
<span className="block text-[11px] leading-relaxed text-db-muted">
Vollzugriff auf alle Projekte, Benutzerverwaltung und Protokoll – unabhängig von den
Projektrollen. Sparsam vergeben.
{eigenerZugang && " Am eigenen Zugang nicht abwählbar."}
</span>
</span>
</label>
</div>
<p className="sm:col-span-3 text-[11px] text-db-muted">
Projektrollen werden nicht hier, sondern im jeweiligen Projekt unter <em>Einstellungen →
Beteiligte</em> vergeben. Das hält die Zuständigkeit dort, wo sie fachlich hingehört.
</p>
</div>
);
}
+236
View File
@@ -0,0 +1,236 @@
"use server";
import { revalidatePath } from "next/cache";
import { randomInt } from "crypto";
import { prisma } from "@/lib/db";
import { requireUser, hashPassword } from "@/lib/auth";
import { protokolliere, benachrichtige } from "@/lib/notify";
import { sendeMail, htmlRahmen } from "@/lib/mail";
import type { CompanyType } from "@prisma/client";
async function nurAdmin() {
const user = await requireUser();
if (!user.isAdmin) throw new Error("Diese Funktion ist der Administration vorbehalten.");
return user;
}
/**
* Startkennwort erzeugen. Bewusst gut vorlesbar und fehlerarm –
* es wird typischerweise am Telefon oder auf Papier weitergegeben.
* Ohne 0/O und 1/l/I, damit nichts verwechselt wird.
*/
function startkennwort(): string {
const zeichen = "abcdefghijkmnpqrstuvwxyz";
const ziffern = "23456789";
const gross = "ABCDEFGHJKLMNPQRSTUVWXYZ";
const teil = (menge: string, n: number) =>
Array.from({ length: n }, () => menge[randomInt(menge.length)]).join("");
return `${gross[randomInt(gross.length)]}${teil(zeichen, 5)}-${teil(zeichen, 5)}-${teil(ziffern, 4)}`;
}
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
// ═══════════════════════════════════════════════════════════════════
// Benutzer
// ═══════════════════════════════════════════════════════════════════
export async function benutzerAnlegen(formData: FormData) {
const admin = await nurAdmin();
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const name = String(formData.get("name") ?? "").trim();
if (!EMAIL.test(email)) throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
if (name.length < 2) throw new Error("Bitte den vollständigen Namen angeben.");
if (await prisma.user.findUnique({ where: { email } }))
throw new Error(`Für „${email}“ besteht bereits ein Zugang.`);
const kennwort = startkennwort();
const benutzer = await prisma.user.create({
data: {
email,
name,
passwordHash: await hashPassword(kennwort),
position: String(formData.get("position") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
companyId: String(formData.get("companyId") ?? "") || null,
isAdmin: formData.get("isAdmin") === "on",
mustChangePw: true,
},
});
await protokolliere({
userId: admin.id, aktion: "BENUTZER_ANGELEGT", entitaet: "User",
entitaetId: benutzer.id, details: { email, isAdmin: benutzer.isAdmin },
});
if (formData.get("mailSenden") === "on") {
const url = process.env.APP_URL || "http://localhost:3400";
const text =
`Guten Tag ${name},\n\n` +
`für Sie wurde ein Zugang zu BauDoc Bahn eingerichtet.\n\n` +
`Adresse: ${url}\n` +
`Benutzer: ${email}\n` +
`Kennwort: ${kennwort}\n\n` +
`Das Kennwort ist beim ersten Anmelden zu ändern.`;
await sendeMail({
an: email,
betreff: "Ihr Zugang zu BauDoc Bahn",
text,
html: htmlRahmen("Ihr Zugang zu BauDoc Bahn", text, "/anmelden"),
});
}
revalidatePath("/verwaltung");
return { id: benutzer.id, email, name, kennwort };
}
export async function benutzerBearbeiten(formData: FormData) {
const admin = await nurAdmin();
const id = String(formData.get("userId"));
const vorher = await prisma.user.findUniqueOrThrow({ where: { id } });
const istAdminNeu = formData.get("isAdmin") === "on";
if (id === admin.id && !istAdminNeu)
throw new Error("Sie können sich die eigenen Administrationsrechte nicht entziehen.");
if (vorher.isAdmin && !istAdminNeu) {
const anzahl = await prisma.user.count({ where: { isAdmin: true, active: true } });
if (anzahl <= 1) throw new Error("Es muss mindestens ein aktiver Administrationszugang bestehen.");
}
const email = String(formData.get("email") ?? "").trim().toLowerCase();
if (!EMAIL.test(email)) throw new Error("Bitte eine gültige E-Mail-Adresse angeben.");
if (email !== vorher.email && (await prisma.user.findUnique({ where: { email } })))
throw new Error(`Für „${email}“ besteht bereits ein Zugang.`);
await prisma.user.update({
where: { id },
data: {
email,
name: String(formData.get("name") ?? "").trim(),
position: String(formData.get("position") ?? "").trim() || null,
phone: String(formData.get("phone") ?? "").trim() || null,
companyId: String(formData.get("companyId") ?? "") || null,
isAdmin: istAdminNeu,
},
});
await protokolliere({
userId: admin.id, aktion: "BENUTZER_GEAENDERT", entitaet: "User",
entitaetId: id, details: { email, isAdmin: istAdminNeu },
});
revalidatePath("/verwaltung");
}
/**
* Zugänge werden gesperrt, nicht gelöscht: Bautagebucheinträge, Prüfvermerke
* und Bescheinigungen müssen dauerhaft einer Person zuordenbar bleiben.
*/
export async function benutzerStatus(userId: string, aktiv: boolean) {
const admin = await nurAdmin();
const benutzer = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
if (userId === admin.id && !aktiv) throw new Error("Sie können den eigenen Zugang nicht sperren.");
if (benutzer.isAdmin && !aktiv) {
const anzahl = await prisma.user.count({ where: { isAdmin: true, active: true } });
if (anzahl <= 1) throw new Error("Es muss mindestens ein aktiver Administrationszugang bestehen.");
}
await prisma.user.update({ where: { id: userId }, data: { active: aktiv } });
if (!aktiv) await prisma.session.deleteMany({ where: { userId } }); // laufende Sitzungen beenden
await protokolliere({
userId: admin.id, aktion: aktiv ? "BENUTZER_ENTSPERRT" : "BENUTZER_GESPERRT",
entitaet: "User", entitaetId: userId, details: { email: benutzer.email },
});
revalidatePath("/verwaltung");
}
export async function kennwortZuruecksetzen(userId: string, perMail: boolean) {
const admin = await nurAdmin();
const benutzer = await prisma.user.findUniqueOrThrow({ where: { id: userId } });
const kennwort = startkennwort();
await prisma.user.update({
where: { id: userId },
data: { passwordHash: await hashPassword(kennwort), mustChangePw: true },
});
await prisma.session.deleteMany({ where: { userId } });
await protokolliere({
userId: admin.id, aktion: "KENNWORT_ZURUECKGESETZT", entitaet: "User",
entitaetId: userId, details: { email: benutzer.email, perMail },
});
if (perMail) {
const url = process.env.APP_URL || "http://localhost:3400";
const text =
`Guten Tag ${benutzer.name},\n\n` +
`Ihr Kennwort für BauDoc Bahn wurde von der Administration zurückgesetzt.\n\n` +
`Adresse: ${url}\n` +
`Benutzer: ${benutzer.email}\n` +
`Kennwort: ${kennwort}\n\n` +
`Bitte beim nächsten Anmelden ein eigenes Kennwort vergeben.`;
await sendeMail({
an: benutzer.email,
betreff: "Ihr Kennwort für BauDoc Bahn wurde zurückgesetzt",
text,
html: htmlRahmen("Kennwort zurückgesetzt", text, "/anmelden"),
});
}
revalidatePath("/verwaltung");
return { name: benutzer.name, email: benutzer.email, kennwort };
}
// ═══════════════════════════════════════════════════════════════════
// Firmen
// ═══════════════════════════════════════════════════════════════════
export async function firmaSpeichern(formData: FormData) {
const admin = await nurAdmin();
const id = String(formData.get("companyId") ?? "");
const daten = {
name: String(formData.get("name") ?? "").trim(),
shortName: String(formData.get("shortName") ?? "").trim() || null,
type: (formData.get("type") as CompanyType) || "SONSTIGE",
street: String(formData.get("street") ?? "").trim() || null,
zip: String(formData.get("zip") ?? "").trim() || null,
city: String(formData.get("city") ?? "").trim() || null,
ustId: String(formData.get("ustId") ?? "").trim() || null,
kreditorNr: String(formData.get("kreditorNr") ?? "").trim() || null,
contactEmail: String(formData.get("contactEmail") ?? "").trim() || null,
contactPhone: String(formData.get("contactPhone") ?? "").trim() || null,
notes: String(formData.get("notes") ?? "").trim() || null,
};
if (daten.name.length < 2) throw new Error("Bitte den Firmennamen angeben.");
const firma = id
? await prisma.company.update({ where: { id }, data: daten })
: await prisma.company.create({ data: daten });
await protokolliere({
userId: admin.id, aktion: id ? "FIRMA_GEAENDERT" : "FIRMA_ANGELEGT",
entitaet: "Company", entitaetId: firma.id, details: { name: daten.name },
});
revalidatePath("/verwaltung/firmen");
}
export async function firmaStatus(companyId: string, aktiv: boolean) {
const admin = await nurAdmin();
const firma = await prisma.company.findUniqueOrThrow({
where: { id: companyId },
include: { _count: { select: { users: true, contracts: true } } },
});
if (!aktiv && firma._count.contracts > 0)
throw new Error(
`„${firma.name}“ hat ${firma._count.contracts} Verträge und kann nicht stillgelegt werden.`,
);
await prisma.company.update({ where: { id: companyId }, data: { active: aktiv } });
await protokolliere({
userId: admin.id, aktion: aktiv ? "FIRMA_AKTIV" : "FIRMA_STILLGELEGT",
entitaet: "Company", entitaetId: companyId, details: { name: firma.name },
});
revalidatePath("/verwaltung/firmen");
}
@@ -0,0 +1,240 @@
"use client";
import { Fragment, useState, useTransition } from "react";
import { Building2, Check, Pencil, Power, Plus, X } from "lucide-react";
import { firmaSpeichern, firmaStatus } from "../actions";
import { StatusChip } from "@/components/ui";
type Firma = {
id: string;
name: string;
shortName: string | null;
type: string;
street: string | null;
zip: string | null;
city: string | null;
ustId: string | null;
kreditorNr: string | null;
contactEmail: string | null;
contactPhone: string | null;
notes: string | null;
active: boolean;
nutzer: number;
vertraege: number;
rechnungen: number;
};
const TYPEN: [string, string][] = [
["EIU", "Eisenbahninfrastrukturunternehmen (Bauherr)"],
["AUFTRAGNEHMER", "Auftragnehmer / Baufirma"],
["PLANUNGSBUERO", "Planungsbüro"],
["GUTACHTER", "Gutachter, Prüfingenieur, Sachverständiger"],
["SICHERUNGSUNTERNEHMEN", "Sicherungsunternehmen"],
["BEHOERDE", "Behörde (EBA, Kommune, Land)"],
["BETREIBER", "Anlagenbetreiber / Instandhaltung"],
["SONSTIGE", "Sonstige"],
];
const TYP_LABEL = Object.fromEntries(TYPEN);
export function Firmenliste({ firmen }: { firmen: Firma[] }) {
const [neu, setNeu] = useState(false);
const [bearbeitet, setBearbeitet] = useState<string | null>(null);
const [fehler, setFehler] = useState<string | null>(null);
const [pending, start] = useTransition();
const lauf = (fn: () => Promise<unknown>) =>
start(async () => {
setFehler(null);
try {
await fn();
} catch (e) {
setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich.");
}
});
return (
<div className="space-y-4">
{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 items-center justify-between">
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
Der Firmenstamm bestimmt, wessen Daten ein Auftragnehmer sehen darf – die Firmenzuordnung
eines Zugangs ist die Grenze, an der die Sicht endet. Die Kreditorennummer erscheint in der
Rechnungsprüfung und erleichtert den Abgleich mit der Buchhaltung.
</p>
<button onClick={() => { setNeu(!neu); setBearbeitet(null); }} className="btn-primary shrink-0">
<Building2 size={15} /> Firma anlegen
</button>
</div>
{neu && (
<form
className="card space-y-3 p-4"
action={(fd) => lauf(async () => { await firmaSpeichern(fd); setNeu(false); })}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">Neue Firma</h2>
<button type="button" onClick={() => setNeu(false)} className="text-db-muted hover:text-white">
<X size={17} />
</button>
</div>
<FirmaFelder />
<button type="submit" disabled={pending} className="btn-primary">
<Plus size={14} /> {pending ? "wird angelegt …" : "Anlegen"}
</button>
</form>
)}
<div className="card overflow-hidden">
<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">Anschrift</th>
<th className="th">Kreditor / USt-IdNr.</th>
<th className="th text-right">Nutzer</th>
<th className="th text-right">Verträge</th>
<th className="th w-44 text-right">Aktion</th>
</tr>
</thead>
<tbody>
{firmen.map((f) => (
<Fragment key={f.id}>
<tr className={`row ${!f.active ? "opacity-50" : ""}`}>
<td className="td">
<div className="flex items-center gap-2">
<span className="text-slate-100">{f.name}</span>
{!f.active && <StatusChip status="ENTFAELLT" text="stillgelegt" />}
</div>
<div className="text-[11px] text-db-muted">
{f.shortName && `${f.shortName} · `}
{f.contactEmail ?? "keine Kontaktadresse"}
{f.contactPhone && ` · ${f.contactPhone}`}
</div>
</td>
<td className="td text-xs text-slate-400">{TYP_LABEL[f.type] ?? f.type}</td>
<td className="td text-xs text-slate-400">
{f.street && <div>{f.street}</div>}
{(f.zip || f.city) && <div>{[f.zip, f.city].filter(Boolean).join(" ")}</div>}
{!f.street && !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.nutzer}</td>
<td className="td text-right tabular-nums text-slate-300">{f.vertraege}</td>
<td className="td">
<div className="flex items-center justify-end gap-1.5">
<button
onClick={() => { setBearbeitet(bearbeitet === f.id ? null : f.id); setNeu(false); }}
className="btn-ghost btn-sm"
>
<Pencil size={12} /> bearbeiten
</button>
<button
disabled={pending}
onClick={() => lauf(() => firmaStatus(f.id, !f.active))}
className="btn-ghost btn-sm"
title={f.active ? "stilllegen" : "wieder aktivieren"}
>
<Power size={12} className={f.active ? "" : "text-emerald-400"} />
</button>
</div>
</td>
</tr>
{bearbeitet === f.id && (
<tr className="border-t border-db-line bg-db-dark/40">
<td className="td" colSpan={7}>
<form
className="space-y-3 p-1"
action={(fd) => lauf(async () => { await firmaSpeichern(fd); setBearbeitet(null); })}
>
<input type="hidden" name="companyId" value={f.id} />
<FirmaFelder werte={f} />
<div className="flex gap-2">
<button type="submit" disabled={pending} className="btn-primary btn-sm">
<Check size={13} /> Speichern
</button>
<button type="button" onClick={() => setBearbeitet(null)} className="btn-ghost btn-sm">
Abbrechen
</button>
</div>
</form>
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
<p className="text-xs text-db-muted">
Firmen mit bestehenden Verträgen lassen sich nicht stilllegen. Stilllegen entfernt die Firma
aus den Auswahllisten, lässt aber alle Vorgänge unangetastet.
</p>
</div>
);
}
function FirmaFelder({ werte }: { werte?: Firma }) {
return (
<div className="grid gap-3 sm:grid-cols-4">
<div className="sm:col-span-2">
<label className="label mb-1.5 block">Firmenname</label>
<input name="name" required defaultValue={werte?.name ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kurzname</label>
<input name="shortName" defaultValue={werte?.shortName ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Art</label>
<select name="type" defaultValue={werte?.type ?? "AUFTRAGNEHMER"} 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">Straße</label>
<input name="street" defaultValue={werte?.street ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">PLZ</label>
<input name="zip" defaultValue={werte?.zip ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Ort</label>
<input name="city" defaultValue={werte?.city ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kreditorennummer</label>
<input name="kreditorNr" defaultValue={werte?.kreditorNr ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">USt-IdNr.</label>
<input name="ustId" defaultValue={werte?.ustId ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kontakt E-Mail</label>
<input name="contactEmail" type="email" defaultValue={werte?.contactEmail ?? ""} className="input" />
</div>
<div>
<label className="label mb-1.5 block">Kontakt Telefon</label>
<input name="contactPhone" defaultValue={werte?.contactPhone ?? ""} className="input" />
</div>
<div className="sm:col-span-4">
<label className="label mb-1.5 block">Bemerkung</label>
<input name="notes" defaultValue={werte?.notes ?? ""} className="input" />
</div>
</div>
);
}
@@ -0,0 +1,39 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { Firmenliste } from "./Firmenliste";
export const dynamic = "force-dynamic";
export default async function FirmenVerwaltung() {
const user = await requireUser();
if (!user.isAdmin) redirect("/uebersicht");
const firmen = await prisma.company.findMany({
include: { _count: { select: { users: true, contracts: true, invoices: true } } },
orderBy: [{ active: "desc" }, { type: "asc" }, { name: "asc" }],
});
return (
<Firmenliste
firmen={firmen.map((f) => ({
id: f.id,
name: f.name,
shortName: f.shortName,
type: f.type,
street: f.street,
zip: f.zip,
city: f.city,
ustId: f.ustId,
kreditorNr: f.kreditorNr,
contactEmail: f.contactEmail,
contactPhone: f.contactPhone,
notes: f.notes,
active: f.active,
nutzer: f._count.users,
vertraege: f._count.contracts,
rechnungen: f._count.invoices,
}))}
/>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { TabLink } from "@/components/NavLink";
import { Kachel } from "@/components/ui";
export const dynamic = "force-dynamic";
export default async function VerwaltungLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
if (!user.isAdmin) redirect("/uebersicht");
const [benutzer, gesperrt, firmen, projekte, sollUnterlagen, ordner, speicher, sitzungen] =
await Promise.all([
prisma.user.count(),
prisma.user.count({ where: { active: false } }),
prisma.company.count({ where: { active: true } }),
prisma.project.count(),
prisma.documentRequirement.count(),
prisma.filingNode.count(),
prisma.documentVersion.aggregate({ _sum: { groesse: true } }),
prisma.session.count({ where: { expiresAt: { gt: new Date() } } }),
]);
const mb = Number(speicher._sum.groesse ?? 0) / 1048576;
return (
<div className="space-y-5">
<div>
<h1 className="text-xl font-semibold text-white">Verwaltung</h1>
<p className="mt-0.5 text-sm text-db-muted">
Zugänge, Firmenstamm, Rechtemodell und Protokoll
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kachel
titel="Zugänge"
wert={benutzer}
hinweis={`${gesperrt} gesperrt · ${sitzungen} aktive Sitzungen`}
/>
<Kachel titel="Firmen" wert={firmen} hinweis="aktiv im Stamm" />
<Kachel titel="Projekte" wert={projekte} />
<Kachel
titel="Ablagestruktur"
wert={sollUnterlagen}
hinweis={`${ordner} Ordner · ${mb.toFixed(1)} MB im Dateispeicher`}
/>
</div>
<nav className="flex gap-1 overflow-x-auto border-b border-db-line">
<TabLink href="/verwaltung" exact>
Benutzer
</TabLink>
<TabLink href="/verwaltung/firmen">Firmen</TabLink>
<TabLink href="/verwaltung/rollen">Rollen &amp; Rechte</TabLink>
<TabLink href="/verwaltung/protokoll">Protokoll</TabLink>
</nav>
{children}
</div>
);
}
+34 -132
View File
@@ -1,147 +1,49 @@
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";
import { Benutzerliste } from "./Benutzerliste";
export const dynamic = "force-dynamic";
export default async function Verwaltung() {
const user = await requireUser();
if (!user.isAdmin) redirect("/uebersicht");
export default async function BenutzerVerwaltung() {
const admin = await requireUser();
const [benutzer, firmen, protokoll, stats] = await Promise.all([
const [benutzer, firmen] = await Promise.all([
prisma.user.findMany({
include: { company: { select: { name: true } }, memberships: { select: { role: true } } },
include: {
company: { select: { id: true, name: true, shortName: true } },
memberships: { select: { role: true, project: { select: { nummer: true } } } },
_count: { select: { sessions: true } },
},
orderBy: [{ active: "desc" }, { name: "asc" }],
}),
prisma.company.findMany({
where: { active: true },
select: { id: true, name: true, shortName: true, type: 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>
<Benutzerliste
eigeneId={admin.id}
benutzer={benutzer.map((b) => ({
id: b.id,
email: b.email,
name: b.name,
position: b.position,
phone: b.phone,
active: b.active,
isAdmin: b.isAdmin,
mustChangePw: b.mustChangePw,
companyId: b.companyId,
firma: b.company?.shortName ?? b.company?.name ?? null,
lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null,
sitzungen: b._count.sessions,
rollen: [...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))],
projekte: b.memberships.length,
}))}
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))}
/>
);
}
@@ -0,0 +1,169 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import { datumZeit } from "@/lib/format";
import { Leer } from "@/components/ui";
export const dynamic = "force-dynamic";
/** Aktionen, die aufbewahrungs- oder haftungsrelevant sind. */
const GEWICHTIG = new Set([
"SACHLICH_RICHTIG", "RECHNERISCH_RICHTIG", "ZAHLUNGSFREIGABE", "ZURUECKWEISUNG",
"FREIGABE", "ABSCHLUSS", "BEFUND_ERLEDIGT", "KENNWORT_ZURUECKGESETZT",
"BENUTZER_GESPERRT", "BENUTZER_ANGELEGT", "EINSTELLUNGEN",
]);
export default async function Protokoll({
searchParams,
}: {
searchParams: Promise<{ aktion?: string; q?: string; seite?: string }>;
}) {
const user = await requireUser();
if (!user.isAdmin) redirect("/uebersicht");
const sp = await searchParams;
const seite = Math.max(1, Number(sp.seite ?? 1));
const proSeite = 100;
const filter = {
...(sp.aktion ? { aktion: sp.aktion } : {}),
...(sp.q
? {
OR: [
{ entitaet: { contains: sp.q, mode: "insensitive" as const } },
{ entitaetId: { contains: sp.q } },
{ user: { name: { contains: sp.q, mode: "insensitive" as const } } },
],
}
: {}),
};
const [eintraege, gesamt, aktionen] = await Promise.all([
prisma.auditLog.findMany({
where: filter,
include: { user: { select: { name: true, email: true } } },
orderBy: { createdAt: "desc" },
skip: (seite - 1) * proSeite,
take: proSeite,
}),
prisma.auditLog.count({ where: filter }),
prisma.auditLog.groupBy({ by: ["aktion"], _count: true, orderBy: { _count: { aktion: "desc" } } }),
]);
const seiten = Math.ceil(gesamt / proSeite);
const link = (o: Record<string, string | undefined>) => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({ aktion: sp.aktion, q: sp.q, ...o })) if (v) p.set(k, v);
return `/verwaltung/protokoll?${p.toString()}`;
};
return (
<div className="space-y-4">
<p className="max-w-3xl text-xs leading-relaxed text-db-muted">
Jede Anmeldung, jeder Dateiabruf, jede Freigabe und jede Bescheinigung wird festgehalten.
Das Protokoll ist die Grundlage dafür, auch Jahre später zu belegen, wer wann was erklärt
hat – bei Aufbewahrungsfristen bis 38 Jahre nach Bauschlussmeldung der eigentliche Zweck
der Sache. Einträge lassen sich nicht bearbeiten oder löschen.
</p>
<form className="card flex flex-wrap items-center gap-2 p-3" action="/verwaltung/protokoll">
<input
name="q"
defaultValue={sp.q ?? ""}
placeholder="Person, Objektart oder Kennung …"
className="input w-72 py-1.5 text-xs"
/>
<select name="aktion" defaultValue={sp.aktion ?? ""} className="input w-64 py-1.5 text-xs">
<option value="">alle Aktionen</option>
{aktionen.map((a) => (
<option key={a.aktion} value={a.aktion}>
{a.aktion} ({a._count})
</option>
))}
</select>
<button className="btn-ghost btn-sm" type="submit">
Filtern
</button>
{(sp.q || sp.aktion) && (
<Link href="/verwaltung/protokoll" className="text-xs text-db-muted hover:text-slate-200">
zurücksetzen
</Link>
)}
<span className="ml-auto text-xs text-db-muted">
{gesamt.toLocaleString("de-DE")} Einträge
{seiten > 1 && ` · Seite ${seite} von ${seiten}`}
</span>
</form>
{eintraege.length === 0 ? (
<Leer text="Keine Protokolleinträge für diese Auswahl." />
) : (
<div className="card overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th w-40">Zeitpunkt</th>
<th className="th w-44">Person</th>
<th className="th w-52">Aktion</th>
<th className="th">Objekt und Einzelheiten</th>
</tr>
</thead>
<tbody>
{eintraege.map((e) => (
<tr key={e.id} className="row">
<td className="td whitespace-nowrap text-xs tabular-nums text-db-muted">
{datumZeit(e.createdAt)}
</td>
<td className="td text-xs text-slate-300">
{e.user?.name ?? <span className="text-db-muted">System</span>}
</td>
<td className="td">
<span
className={`chip ${
GEWICHTIG.has(e.aktion)
? "border-db-cyan/50 bg-db-cyan/10 text-db-cyan"
: "border-db-line bg-db-dark text-slate-400"
}`}
>
{e.aktion}
</span>
</td>
<td className="td text-xs text-slate-400">
{e.entitaet}
{e.entitaetId && (
<span className="ml-1 font-mono text-[10px] text-db-muted">{e.entitaetId.slice(0, 12)}…</span>
)}
{e.details ? (
<div className="mt-0.5 break-all font-mono text-[10px] leading-relaxed text-db-muted">
{JSON.stringify(e.details)}
</div>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{seiten > 1 && (
<div className="flex items-center justify-center gap-2">
{seite > 1 && (
<Link href={link({ seite: String(seite - 1) })} className="btn-ghost btn-sm">
zurück
</Link>
)}
<span className="text-xs text-db-muted">
Seite {seite} von {seiten}
</span>
{seite < seiten && (
<Link href={link({ seite: String(seite + 1) })} className="btn-ghost btn-sm">
weiter
</Link>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,218 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth";
import { prisma } from "@/lib/db";
import {
PERMISSIONS, PERMISSION_INFO, PERMISSION_GRUPPEN, ROLE_LABEL, ROLE_HINT,
ROLE_KURZ, ROLE_SEITE, OPTION_INFO, can, type Permission,
} from "@/lib/rbac";
import type { Role } from "@prisma/client";
export const dynamic = "force-dynamic";
const SEITE_TEXT = {
AG: { titel: "Auftraggeberseite", farbe: "border-db-cyan/50 bg-db-cyan/10 text-db-cyan" },
AN: { titel: "Auftragnehmerseite", farbe: "border-amber-700 bg-amber-900/25 text-amber-300" },
NEUTRAL: { titel: "Zuarbeit und Mitlesende", farbe: "border-db-line bg-db-dark text-slate-400" },
} as const;
export default async function Rollenlegende() {
const user = await requireUser();
if (!user.isAdmin) redirect("/uebersicht");
const rollen = Object.keys(ROLE_LABEL) as Role[];
const rechte = Object.keys(PERMISSIONS) as Permission[];
const belegung = await prisma.projectMember.groupBy({ by: ["role"], _count: true });
const anzahl = new Map(belegung.map((b) => [b.role, b._count]));
return (
<div className="space-y-5">
{/* ── Grundprinzip ─────────────────────────────────────── */}
<section className="card-pad">
<h2 className="text-sm font-semibold text-white">Das Grundprinzip</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-relaxed text-slate-300">
Die Rollen bilden <strong>nicht ab, wer wem vorgesetzt ist</strong>, sondern auf welcher
Seite des Bauvertrags jemand steht. An dieser Linie hängen alle Rechte. Darüber liegt die
Vier-Augen-Pflicht aus dem Vorgabeprozess Bauüberwachung: <em>sachlich richtig</em> und{" "}
<em>rechnerisch richtig</em> können technisch nie von derselben Person bescheinigt werden.
</p>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">
Rollen werden <strong>je Projekt</strong> vergeben – im Projekt unter{" "}
<em>Einstellungen → Beteiligte</em>, nicht hier. Dieselbe Person kann in einem Projekt
Bauüberwacher und in einem anderen nur Leser sein.
</p>
</section>
{/* ── Rollen ───────────────────────────────────────────── */}
{(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
if (gruppe.length === 0) return null;
return (
<section key={seite} className="card overflow-hidden">
<header className="flex items-center gap-2 border-b border-db-line bg-db-dark/40 px-4 py-2.5">
<span className={`chip ${SEITE_TEXT[seite].farbe}`}>{SEITE_TEXT[seite].titel}</span>
<span className="text-xs text-db-muted">
{seite === "AG" && "vergibt, überwacht, prüft, gibt frei – sieht das gesamte Projekt"}
{seite === "AN" && "liefert, meldet, rechnet ab – sieht ausschließlich die eigene Firma"}
{seite === "NEUTRAL" && "arbeiten zu oder lesen mit"}
</span>
</header>
<table className="w-full">
<tbody>
{gruppe.map((r) => (
<tr key={r} className="row">
<td className="td w-16">
<span className="rounded bg-db-line px-1.5 py-0.5 font-mono text-[11px] text-slate-300">
{ROLE_KURZ[r]}
</span>
</td>
<td className="td w-64 text-slate-100">{ROLE_LABEL[r]}</td>
<td className="td text-sm leading-relaxed text-db-muted">{ROLE_HINT[r]}</td>
<td className="td w-32 whitespace-nowrap text-right text-xs tabular-nums text-db-muted">
{anzahl.get(r) ?? 0}× vergeben
</td>
</tr>
))}
</tbody>
</table>
</section>
);
})}
{/* ── Matrix ───────────────────────────────────────────── */}
<section className="card overflow-hidden">
<header className="border-b border-db-line px-4 py-3">
<h2 className="text-sm font-semibold text-white">Rechtematrix</h2>
<p className="mt-0.5 text-xs text-db-muted">
Unmittelbar aus <code className="text-slate-300">src/lib/rbac.ts</code> erzeugt – diese
Tabelle kann nicht veralten. Ein <span className="text-amber-400">○</span> bedeutet: für
Auftragnehmer zusätzlich vom Projektschalter abhängig.
</p>
</header>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th sticky left-0 bg-db-slate">Recht</th>
{rollen.map((r) => (
<th key={r} className="th text-center" title={ROLE_LABEL[r]}>
{ROLE_KURZ[r]}
</th>
))}
</tr>
</thead>
<tbody>
{PERMISSION_GRUPPEN.map((gruppe) => {
const inGruppe = rechte.filter((p) => PERMISSION_INFO[p].gruppe === gruppe);
if (inGruppe.length === 0) return null;
return (
<Fragmentartig key={gruppe}>
<tr className="border-t border-db-line/70 bg-db-dark/40">
<td className="td sticky left-0 bg-db-dark/40 text-[11px] font-semibold uppercase tracking-wider text-db-muted" colSpan={rollen.length + 1}>
{gruppe}
</td>
</tr>
{inGruppe.map((p) => {
const info = PERMISSION_INFO[p] as { label: string; optional?: string };
return (
<tr key={p} className="row">
<td className="td sticky left-0 bg-db-slate text-sm text-slate-200">
{info.label}
{info.optional && (
<span className="ml-1.5 text-[10px] text-amber-400/70">
Schalter: {info.optional}
</span>
)}
</td>
{rollen.map((r) => {
const erlaubt = can([r], p);
const optional = !erlaubt && r === "AUFTRAGNEHMER" && !!info.optional;
return (
<td key={r} className="td text-center">
{erlaubt ? (
<span className="text-emerald-400">✓</span>
) : optional ? (
<span className="text-amber-400" title="hängt am Projektschalter">○</span>
) : (
<span className="text-slate-700">·</span>
)}
</td>
);
})}
</tr>
);
})}
</Fragmentartig>
);
})}
</tbody>
</table>
</div>
</section>
{/* ── Projektschalter ──────────────────────────────────── */}
<section className="card overflow-hidden">
<header className="border-b border-db-line px-4 py-3">
<h2 className="text-sm font-semibold text-white">Projektschalter für Auftragnehmer</h2>
<p className="mt-0.5 max-w-3xl text-xs leading-relaxed text-db-muted">
Je Projekt einstellbar unter <em>Projekt → Einstellungen</em>. Die Schalter entscheiden
ausschließlich, <strong>ob ein Bereich sichtbar ist</strong> – niemals, wessen Daten darin
erscheinen. Ein Auftragnehmer sieht in jedem Fall nur die eigene Firma.
</p>
</header>
<table className="w-full">
<thead>
<tr className="border-b border-db-line">
<th className="th">Schalter</th>
<th className="th">Wirkung</th>
<th className="th w-32 text-center">Voreinstellung</th>
</tr>
</thead>
<tbody>
{OPTION_INFO.map((o) => (
<tr key={o.feld} className="row">
<td className="td text-slate-100">{o.titel}</td>
<td className="td text-sm leading-relaxed text-db-muted">{o.hinweis}</td>
<td className="td text-center">
<span className={`chip ${o.standard ? "border-emerald-700 bg-emerald-900/25 text-emerald-300" : "border-slate-600 bg-slate-700/30 text-slate-400"}`}>
{o.standard ? "an" : "aus"}
</span>
</td>
</tr>
))}
<tr className="row">
<td className="td text-slate-100">Bescheinigung trotz kritischer Befunde</td>
<td className="td text-sm leading-relaxed text-db-muted">
Erlaubt der Bauüberwachung, eine Rechnung auch mit offenen kritischen Prüfbefunden
sachlich zu bescheinigen – nur mit Begründung und protokolliert. Abgeschaltet müssen
zuerst alle Befunde als geklärt vermerkt sein.
</td>
<td className="td text-center">
<span className="chip border-emerald-700 bg-emerald-900/25 text-emerald-300">an</span>
</td>
</tr>
</tbody>
</table>
</section>
{/* ── Feste Sperren ────────────────────────────────────── */}
<section className="card-pad">
<h2 className="text-sm font-semibold text-white">Nicht einstellbar – und das mit Absicht</h2>
<ul className="mt-2 space-y-1.5 text-sm leading-relaxed text-slate-300">
<li>· Sachlich und rechnerisch richtig kann nie dieselbe Person bescheinigen.</li>
<li>· Die Bauüberwachung bescheinigt, gibt aber keine Zahlung frei – das tun PL oder BHV.</li>
<li>· Der Fachbauüberwacher arbeitet dem Bauüberwacher zu und bescheinigt nicht selbst.</li>
<li>· Auftragnehmer sehen ausschließlich Daten der eigenen Firma.</li>
<li>· Wer nicht im Projekt eingetragen ist, sieht das Projekt nicht – auch nicht in der Liste.</li>
<li>· Es muss stets mindestens ein aktiver Administrationszugang bestehen.</li>
</ul>
</section>
</div>
);
}
/** Kleiner Helfer, damit Gruppenkopf und Zeilen einen gemeinsamen Schlüssel haben. */
function Fragmentartig({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
+2 -1
View File
@@ -15,7 +15,8 @@ export default async function Anmelden({
const passwort = String(formData.get("passwort") ?? "");
const res = await login(email, passwort);
if (!res.ok) redirect("/anmelden?fehler=1");
redirect("/uebersicht");
// Startkennwort der Administration: zuerst ein eigenes vergeben
redirect(res.kennwortWechseln ? "/profil?kennwort=erforderlich" : "/uebersicht");
}
return (
+3 -1
View File
@@ -14,6 +14,7 @@ export type SessionUser = {
email: string;
name: string;
isAdmin: boolean;
mustChangePw: boolean;
companyId: string | null;
companyName: string | null;
companyType: string | null;
@@ -62,7 +63,7 @@ export async function login(email: string, password: string) {
path: "/",
expires: expiresAt,
});
return { ok: true as const };
return { ok: true as const, kennwortWechseln: user.mustChangePw };
}
export async function logout() {
@@ -88,6 +89,7 @@ export async function getSessionUser(): Promise<SessionUser | null> {
email: session.user.email,
name: session.user.name,
isAdmin: session.user.isAdmin,
mustChangePw: session.user.mustChangePw,
companyId: session.user.companyId,
companyName: session.user.company?.name ?? null,
companyType: session.user.company?.type ?? null,
+84
View File
@@ -87,6 +87,90 @@ export function assertCan(roles: Role[], permission: Permission) {
}
}
/**
* Klartext für die Rechtelegende. Bewusst als `satisfies Record<Permission, …>`:
* Wer oben ein Recht ergänzt, bekommt hier einen Übersetzungsfehler und kann es
* nicht vergessen.
*/
export const PERMISSION_INFO = {
"projekt.anlegen": { gruppe: "Projekt", label: "Projekt anlegen" },
"projekt.bearbeiten": { gruppe: "Projekt", label: "Projektdaten und Einstellungen ändern" },
"projekt.mitglieder": { gruppe: "Projekt", label: "Beteiligte und Rollen vergeben" },
"dokument.lesen": { gruppe: "Dokumente", label: "Dokumente lesen" },
"dokument.hochladen": { gruppe: "Dokumente", label: "Dokumente einstellen", optional: "anDarfDokumente" },
"dokument.freigeben": { gruppe: "Dokumente", label: "Dokumente prüfen und freigeben" },
"dokument.loeschen": { gruppe: "Dokumente", label: "Dokumente löschen" },
"dokument.intern": { gruppe: "Dokumente", label: "Interne und vertrauliche Unterlagen sehen" },
"ablage.lesen": { gruppe: "Ablagestruktur", label: "Dokumentationsstand einsehen", optional: "anSichtDokumentation" },
"ablage.steuern": { gruppe: "Ablagestruktur", label: "Status und Zuständigkeit setzen" },
"bautagebuch.lesen": { gruppe: "Bautagebuch", label: "Bautagebuch lesen", optional: "anSichtBautagebuch" },
"bautagebuch.schreiben": { gruppe: "Bautagebuch", label: "Bautagebuch führen" },
"bautagebuch.gegenzeichnen": { gruppe: "Bautagebuch", label: "Bautagebuch gegenzeichnen" },
"vorgang.anlegen": { gruppe: "Meldungen", label: "Meldung anlegen" },
"vorgang.bearbeiten": { gruppe: "Meldungen", label: "Meldung bearbeiten" },
"vorgang.schliessen": { gruppe: "Meldungen", label: "Meldung schließen" },
"vertrag.lesen": { gruppe: "Verträge", label: "Verträge einsehen", optional: "anSichtVertrag" },
"vertrag.bearbeiten": { gruppe: "Verträge", label: "Verträge und LV pflegen" },
"nachtrag.pruefen": { gruppe: "Verträge", label: "Nachtrag prüfen und bewerten" },
"stunden.einreichen": { gruppe: "Stunden und Aufmaß", label: "Stundenlohnzettel einreichen" },
"stunden.pruefen": { gruppe: "Stunden und Aufmaß", label: "Stunden zeilenweise anerkennen" },
"aufmass.einreichen": { gruppe: "Stunden und Aufmaß", label: "Aufmaß einreichen" },
"aufmass.pruefen": { gruppe: "Stunden und Aufmaß", label: "Aufmaß anerkennen" },
"rechnung.erfassen": { gruppe: "Rechnungsprüfung", label: "Rechnung erfassen" },
"rechnung.lesen": { gruppe: "Rechnungsprüfung", label: "Rechnungen einsehen", optional: "anSichtRechnungen" },
"rechnung.sachlich": { gruppe: "Rechnungsprüfung", label: "sachlich richtig bescheinigen" },
"rechnung.rechnerisch": { gruppe: "Rechnungsprüfung", label: "rechnerisch richtig bescheinigen" },
"rechnung.freigeben": { gruppe: "Rechnungsprüfung", label: "Zahlung freigeben" },
"chat.schreiben": { gruppe: "Kommunikation", label: "Nachrichten schreiben" },
"chat.kanal.anlegen": { gruppe: "Kommunikation", label: "Kanäle anlegen" },
"admin.benutzer": { gruppe: "Administration", label: "Benutzerverwaltung" },
"admin.firmen": { gruppe: "Administration", label: "Firmenstamm pflegen" },
"admin.audit": { gruppe: "Administration", label: "Protokoll einsehen" },
} as const satisfies Record<Permission, { gruppe: string; label: string; optional?: keyof ProjektOptionen }>;
export const PERMISSION_GRUPPEN = [
"Projekt", "Dokumente", "Ablagestruktur", "Bautagebuch", "Meldungen",
"Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration",
] as const;
export const ROLE_KURZ: Record<Role, string> = {
ADMIN: "ADM",
PROJEKTLEITER: "PL",
BAUHERRENVERTRETER: "BHV",
BAUUEBERWACHER: "BÜB",
FACHBAUUEBERWACHER: "FBÜ",
KAUFMANN: "KFM",
PLANER: "PLA",
AUFTRAGNEHMER: "AN",
PRUEFER: "PRF",
BETREIBER: "BTR",
LESER: "LES",
};
/** Auf welcher Seite des Bauvertrags eine Rolle steht. */
export const ROLE_SEITE: Record<Role, "AG" | "AN" | "NEUTRAL"> = {
ADMIN: "NEUTRAL",
PROJEKTLEITER: "AG",
BAUHERRENVERTRETER: "AG",
BAUUEBERWACHER: "AG",
FACHBAUUEBERWACHER: "AG",
KAUFMANN: "AG",
PLANER: "NEUTRAL",
AUFTRAGNEHMER: "AN",
PRUEFER: "NEUTRAL",
BETREIBER: "NEUTRAL",
LESER: "NEUTRAL",
};
export const ROLE_LABEL: Record<Role, string> = {
ADMIN: "Administration",
PROJEKTLEITER: "Projektleitung (PL)",