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:
co-authored by
Claude Opus 5
parent
807b7ce541
commit
8af6b384ea
@@ -51,6 +51,10 @@ Bewusst schmal gehalten – vier bewegliche Teile, keine Fremddienste:
|
|||||||
- **Eigene Session-Authentifizierung** (bcrypt, HttpOnly-Cookie) statt zusätzlichem Auth-Dienst
|
- **Eigene Session-Authentifizierung** (bcrypt, HttpOnly-Cookie) statt zusätzlichem Auth-Dienst
|
||||||
- **Nodemailer** gegen ein beliebiges SMTP-Relais; im Testbetrieb Mailpit
|
- **Nodemailer** gegen ein beliebiges SMTP-Relais; im Testbetrieb Mailpit
|
||||||
|
|
||||||
|
Kennwörter liegen als bcrypt-Hash (Kostenfaktor 11). Sitzungen sind serverseitig in der
|
||||||
|
Datenbank hinterlegt und lassen sich dadurch gezielt beenden – beim Sperren eines Zugangs,
|
||||||
|
beim Zurücksetzen eines Kennworts und bei jeder Kennwortänderung.
|
||||||
|
|
||||||
### Persistenz ohne Named Volumes
|
### Persistenz ohne Named Volumes
|
||||||
|
|
||||||
Alle persistenten Daten liegen als Bind-Mount direkt im Compose-Verzeichnis:
|
Alle persistenten Daten liegen als Bind-Mount direkt im Compose-Verzeichnis:
|
||||||
@@ -78,7 +82,7 @@ SHA-256-Prüfsumme in der Datenbank, nachträgliche Veränderungen sind erkennba
|
|||||||
| **Stunden & Aufmaß** | Auftragnehmer reicht ein, Bauüberwachung erkennt **zeilenweise** an – das ist die Messlatte der Rechnungsprüfung |
|
| **Stunden & Aufmaß** | Auftragnehmer reicht ein, Bauüberwachung erkennt **zeilenweise** an – das ist die Messlatte der Rechnungsprüfung |
|
||||||
| **Rechnungsprüfung** | Automatischer Abgleich gegen Vertrag, Aufmaß, Stundenzettel und Vorrechnungen; getrennte Bescheinigung *sachlich richtig* / *rechnerisch richtig* / Zahlungsfreigabe |
|
| **Rechnungsprüfung** | Automatischer Abgleich gegen Vertrag, Aufmaß, Stundenzettel und Vorrechnungen; getrennte Bescheinigung *sachlich richtig* / *rechnerisch richtig* / Zahlungsfreigabe |
|
||||||
| **Kommunikation** | Projektkanäle statt E-Mail-Pingpong, `@Nachname`-Erwähnungen, E-Mail-Benachrichtigung je nach Profileinstellung |
|
| **Kommunikation** | Projektkanäle statt E-Mail-Pingpong, `@Nachname`-Erwähnungen, E-Mail-Benachrichtigung je nach Profileinstellung |
|
||||||
| **Verwaltung** | Benutzer, Firmen, revisionssicheres Protokoll aller Vorgänge |
|
| **Verwaltung** | Zugänge anlegen und sperren, Kennwörter zurücksetzen, Firmenstamm, Rollenlegende, revisionssicheres Protokoll |
|
||||||
|
|
||||||
### Rechnungsprüfung – geprüfte Befunde
|
### Rechnungsprüfung – geprüfte Befunde
|
||||||
|
|
||||||
@@ -226,6 +230,36 @@ Soll-Katalog nachziehen* fehlende Positionen nach und rechnet die Fristen aller
|
|||||||
offenen Positionen neu. Einmal angelegte Positionen werden nie gelöscht – nicht mehr
|
offenen Positionen neu. Einmal angelegte Positionen werden nie gelöscht – nicht mehr
|
||||||
zutreffende setzt man einzeln auf „entfällt“, dann bleibt der Grund dokumentiert.
|
zutreffende setzt man einzeln auf „entfällt“, dann bleibt der Grund dokumentiert.
|
||||||
|
|
||||||
|
### Benutzerverwaltung
|
||||||
|
|
||||||
|
Unter *Verwaltung* (nur für Zugänge mit Administrationsrecht), in vier Reitern:
|
||||||
|
|
||||||
|
| Reiter | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| **Benutzer** | Zugänge anlegen, bearbeiten, sperren und entsperren, Kennwort zurücksetzen |
|
||||||
|
| **Firmen** | Firmenstamm mit Art, Anschrift, Kreditorennummer und USt-IdNr. |
|
||||||
|
| **Rollen & Rechte** | Legende aller Rollen und die vollständige Rechtematrix – unmittelbar aus `rbac.ts` erzeugt und damit nie veraltet |
|
||||||
|
| **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person |
|
||||||
|
|
||||||
|
**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt
|
||||||
|
(auf Wunsch zusätzlich per E-Mail versandt). Es ist nirgends im Klartext gespeichert. Wer
|
||||||
|
sich damit anmeldet, landet unmittelbar auf der Profilseite und muss ein eigenes Kennwort
|
||||||
|
vergeben; bis dahin weist ein Streifen im Kopfbereich darauf hin. Ein Zurücksetzen durch die
|
||||||
|
Administration beendet zugleich alle laufenden Sitzungen.
|
||||||
|
|
||||||
|
**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 bis zu 38 Jahren Aufbewahrung nach Bauschlussmeldung ist das
|
||||||
|
keine Nebensache. Sperren beendet sofort alle Sitzungen.
|
||||||
|
|
||||||
|
**Aussperrschutz.** Der eigene Zugang kann weder gesperrt noch der eigenen Administration
|
||||||
|
entzogen werden, und es muss stets mindestens ein aktiver Administrationszugang bestehen.
|
||||||
|
Firmen mit bestehenden Verträgen lassen sich nicht stilllegen.
|
||||||
|
|
||||||
|
**Projektrollen werden nicht hier vergeben**, sondern im jeweiligen Projekt unter
|
||||||
|
*Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt
|
||||||
|
projektübergreifend – entsprechend sparsam vergeben.
|
||||||
|
|
||||||
### Absichtliche Sperren
|
### Absichtliche Sperren
|
||||||
|
|
||||||
Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache:
|
Diese Einschränkungen sind keine Lücken, sondern Zweck der Sache:
|
||||||
|
|||||||
+2
-3
@@ -1,11 +1,10 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const 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: {
|
experimental: {
|
||||||
serverActions: { bodySizeLimit: "250mb" },
|
serverActions: { bodySizeLimit: "250mb" },
|
||||||
},
|
},
|
||||||
eslint: { ignoreDuringBuilds: true },
|
|
||||||
typescript: { ignoreBuildErrors: false },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@@ -76,6 +76,19 @@ export default async function AppLayout({ children }: { children: React.ReactNod
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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>
|
<main className="mx-auto max-w-[1600px] px-5 py-6">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { datumZeit } from "@/lib/format";
|
|||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
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 session = await requireUser();
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
|
|
||||||
@@ -61,6 +65,16 @@ export default async function Profil({ searchParams }: { searchParams: Promise<{
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 && (
|
{sp.fehler && (
|
||||||
<p className="rounded-md border border-db-red/40 bg-db-red/10 px-3 py-2 text-sm text-red-300">
|
<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)."}
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & Rechte</TabLink>
|
||||||
|
<TabLink href="/verwaltung/protokoll">Protokoll</TabLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,147 +1,49 @@
|
|||||||
import { redirect } from "next/navigation";
|
|
||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Kachel, StatusChip } from "@/components/ui";
|
|
||||||
import { ROLE_LABEL } from "@/lib/rbac";
|
import { ROLE_LABEL } from "@/lib/rbac";
|
||||||
import { datumZeit } from "@/lib/format";
|
import { Benutzerliste } from "./Benutzerliste";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Verwaltung() {
|
export default async function BenutzerVerwaltung() {
|
||||||
const user = await requireUser();
|
const admin = await requireUser();
|
||||||
if (!user.isAdmin) redirect("/uebersicht");
|
|
||||||
|
|
||||||
const [benutzer, firmen, protokoll, stats] = await Promise.all([
|
const [benutzer, firmen] = await Promise.all([
|
||||||
prisma.user.findMany({
|
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" },
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<Benutzerliste
|
||||||
<h1 className="text-xl font-semibold text-white">Verwaltung</h1>
|
eigeneId={admin.id}
|
||||||
|
benutzer={benutzer.map((b) => ({
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
id: b.id,
|
||||||
<Kachel titel="Projekte" wert={anzProjekte} />
|
email: b.email,
|
||||||
<Kachel titel="Benutzer" wert={benutzer.length} hinweis={`${firmen.length} Firmen`} />
|
name: b.name,
|
||||||
<Kachel titel="Ablagestruktur" wert={anzSoll} hinweis={`${anzOrdner} Ordner, Katalog aus der EIU-Vorgabe`} />
|
position: b.position,
|
||||||
<Kachel titel="Dokumente" wert={anzDokumente} hinweis={`${mb.toFixed(1)} MB im Dateispeicher`} />
|
phone: b.phone,
|
||||||
</div>
|
active: b.active,
|
||||||
|
isAdmin: b.isAdmin,
|
||||||
<section className="card overflow-hidden">
|
mustChangePw: b.mustChangePw,
|
||||||
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Benutzer</h2>
|
companyId: b.companyId,
|
||||||
<table className="w-full">
|
firma: b.company?.shortName ?? b.company?.name ?? null,
|
||||||
<thead>
|
lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null,
|
||||||
<tr className="border-b border-db-line">
|
sitzungen: b._count.sessions,
|
||||||
<th className="th">Name</th>
|
rollen: [...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))],
|
||||||
<th className="th">E-Mail</th>
|
projekte: b.memberships.length,
|
||||||
<th className="th">Firma</th>
|
}))}
|
||||||
<th className="th">Rollen</th>
|
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))}
|
||||||
<th className="th w-40 text-right">letzte Anmeldung</th>
|
/>
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{benutzer.map((b) => (
|
|
||||||
<tr key={b.id} className="row">
|
|
||||||
<td className="td">
|
|
||||||
<span className="text-slate-100">{b.name}</span>
|
|
||||||
{b.isAdmin && <span className="ml-2 chip border-db-red/50 bg-db-red/15 text-red-300">Admin</span>}
|
|
||||||
{!b.active && <span className="ml-2 chip border-slate-700 bg-slate-800 text-slate-500">gesperrt</span>}
|
|
||||||
<div className="text-[11px] text-db-muted">{b.position}</div>
|
|
||||||
</td>
|
|
||||||
<td className="td text-xs text-slate-400">{b.email}</td>
|
|
||||||
<td className="td text-xs text-slate-400">{b.company?.name ?? "—"}</td>
|
|
||||||
<td className="td text-xs text-slate-400">
|
|
||||||
{[...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))].join(", ") || "—"}
|
|
||||||
</td>
|
|
||||||
<td className="td text-right text-xs tabular-nums text-db-muted">{datumZeit(b.lastLoginAt)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card overflow-hidden">
|
|
||||||
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">Firmen</h2>
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-db-line">
|
|
||||||
<th className="th">Firma</th>
|
|
||||||
<th className="th">Art</th>
|
|
||||||
<th className="th">Ort</th>
|
|
||||||
<th className="th">Kreditor / USt-IdNr.</th>
|
|
||||||
<th className="th text-right">Nutzer</th>
|
|
||||||
<th className="th text-right">Verträge</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{firmen.map((f) => (
|
|
||||||
<tr key={f.id} className="row">
|
|
||||||
<td className="td text-slate-100">{f.name}</td>
|
|
||||||
<td className="td text-xs text-slate-400">{f.type.replace(/_/g, " ").toLowerCase()}</td>
|
|
||||||
<td className="td text-xs text-slate-400">{f.city ?? "—"}</td>
|
|
||||||
<td className="td text-xs text-slate-400">
|
|
||||||
{f.kreditorNr ?? "—"}
|
|
||||||
{f.ustId && <div>{f.ustId}</div>}
|
|
||||||
</td>
|
|
||||||
<td className="td text-right tabular-nums text-slate-300">{f._count.users}</td>
|
|
||||||
<td className="td text-right tabular-nums text-slate-300">{f._count.contracts}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="card overflow-hidden">
|
|
||||||
<h2 className="border-b border-db-line px-4 py-3 text-sm font-semibold text-white">
|
|
||||||
Protokoll <span className="font-normal text-db-muted">– revisionssichere Nachvollziehbarkeit</span>
|
|
||||||
</h2>
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-db-line">
|
|
||||||
<th className="th w-40">Zeitpunkt</th>
|
|
||||||
<th className="th w-40">Person</th>
|
|
||||||
<th className="th w-44">Aktion</th>
|
|
||||||
<th className="th">Objekt</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{protokoll.map((p) => (
|
|
||||||
<tr key={p.id} className="row">
|
|
||||||
<td className="td whitespace-nowrap text-xs tabular-nums text-db-muted">{datumZeit(p.createdAt)}</td>
|
|
||||||
<td className="td text-xs text-slate-300">{p.user?.name ?? "System"}</td>
|
|
||||||
<td className="td">
|
|
||||||
<span className="chip border-db-line bg-db-dark text-slate-300">{p.aktion}</span>
|
|
||||||
</td>
|
|
||||||
<td className="td text-xs text-slate-400">
|
|
||||||
{p.entitaet}
|
|
||||||
{p.entitaetId && <span className="font-mono text-db-muted"> · {p.entitaetId.slice(0, 10)}…</span>}
|
|
||||||
{p.details ? (
|
|
||||||
<div className="font-mono text-[10px] text-db-muted">{JSON.stringify(p.details).slice(0, 140)}</div>
|
|
||||||
) : null}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,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}</>;
|
||||||
|
}
|
||||||
@@ -15,7 +15,8 @@ export default async function Anmelden({
|
|||||||
const passwort = String(formData.get("passwort") ?? "");
|
const passwort = String(formData.get("passwort") ?? "");
|
||||||
const res = await login(email, passwort);
|
const res = await login(email, passwort);
|
||||||
if (!res.ok) redirect("/anmelden?fehler=1");
|
if (!res.ok) redirect("/anmelden?fehler=1");
|
||||||
redirect("/uebersicht");
|
// Startkennwort der Administration: zuerst ein eigenes vergeben
|
||||||
|
redirect(res.kennwortWechseln ? "/profil?kennwort=erforderlich" : "/uebersicht");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
+3
-1
@@ -14,6 +14,7 @@ export type SessionUser = {
|
|||||||
email: string;
|
email: string;
|
||||||
name: string;
|
name: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
mustChangePw: boolean;
|
||||||
companyId: string | null;
|
companyId: string | null;
|
||||||
companyName: string | null;
|
companyName: string | null;
|
||||||
companyType: string | null;
|
companyType: string | null;
|
||||||
@@ -62,7 +63,7 @@ export async function login(email: string, password: string) {
|
|||||||
path: "/",
|
path: "/",
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
});
|
});
|
||||||
return { ok: true as const };
|
return { ok: true as const, kennwortWechseln: user.mustChangePw };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
@@ -88,6 +89,7 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
email: session.user.email,
|
email: session.user.email,
|
||||||
name: session.user.name,
|
name: session.user.name,
|
||||||
isAdmin: session.user.isAdmin,
|
isAdmin: session.user.isAdmin,
|
||||||
|
mustChangePw: session.user.mustChangePw,
|
||||||
companyId: session.user.companyId,
|
companyId: session.user.companyId,
|
||||||
companyName: session.user.company?.name ?? null,
|
companyName: session.user.company?.name ?? null,
|
||||||
companyType: session.user.company?.type ?? null,
|
companyType: session.user.company?.type ?? null,
|
||||||
|
|||||||
@@ -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> = {
|
export const ROLE_LABEL: Record<Role, string> = {
|
||||||
ADMIN: "Administration",
|
ADMIN: "Administration",
|
||||||
PROJEKTLEITER: "Projektleitung (PL)",
|
PROJEKTLEITER: "Projektleitung (PL)",
|
||||||
|
|||||||
Reference in New Issue
Block a user