Rolleneditor: eigene Rollen neben den Standardrollen
Rechte sind jetzt die Grundgröße, eine Rolle ist ein benanntes Bündel davon. Die elf Standardrollen stehen weiterhin im Code und sind unveränderlich – Prüfvermerke und Bescheinigungen berufen sich auf sie. Daneben lassen sich eigene Rollen anlegen. Rolleneditor unter Verwaltung > Rollen & Rechte - Rolle aus einer Standardrolle als Vorlage ableiten oder von Grund auf anlegen - Rechte nach Modul gruppiert ankreuzen, mit Sammelschaltern je Gruppe - Seite des Bauvertrags festlegen (Auftraggeber / Auftragnehmer / Zuarbeit) – bestimmt die Datensicht, unabhängig von der Rechteliste - bearbeiten, stilllegen, löschen; die Rechtematrix zeigt eigene Rollen mit an Grenzen - Administrationsrechte sind nicht über eine Rolle vergebbar, sonst ließe sich über eine selbst gebaute Rolle die Benutzerverwaltung erreichen - eine Rolle ohne jedes Recht wird abgelehnt - eine vergebene Rolle lässt sich weder stilllegen noch löschen - die Vier-Augen-Trennung bleibt unabhängig von der Rolle bestehen Umbau der Rechteauswertung - Rechte werden an genau einer Stelle ermittelt: rechteImProjekt() führt Standardrollen und eigene Rollen zu einer Rechtelage zusammen, die Seiten und Server Actions über rechte.darf() bzw. rechte.pruefe() befragen - die Firmenschranke hängt nun an der Seite des Bauvertrags, die jede Rolle mitbringt, statt an einer festen Liste privilegierter Standardrollen - ProjectMember trägt wahlweise eine Standardrolle oder eine eigene Rolle Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
8af6b384ea
commit
84eb93c59f
@@ -142,7 +142,12 @@ eingegrenzt.
|
|||||||
|
|
||||||
### Vollständige Matrix
|
### Vollständige Matrix
|
||||||
|
|
||||||
Quelle ist `app/src/lib/rbac.ts`; die Tabelle ist von dort erzeugt.
|
Quelle ist `app/src/lib/rbac.ts`; die Tabelle ist von dort erzeugt. In der Anwendung
|
||||||
|
zeigt *Verwaltung → Rollen & Rechte* dieselbe Matrix, dort um die eigenen Rollen ergänzt.
|
||||||
|
|
||||||
|
Ausgewertet werden Rechte an genau einer Stelle: `rechteImProjekt()` führt Standardrollen
|
||||||
|
und eigene Rollen zu einer `Rechtelage` zusammen, die jede Seite und jede Server Action
|
||||||
|
über `rechte.darf(...)` bzw. `rechte.pruefe(...)` befragt.
|
||||||
|
|
||||||
Spalte AN zeigt das Rollenrecht; ein `(○)` bedeutet: greift zusätzlich nur, wenn der
|
Spalte AN zeigt das Rollenrecht; ein `(○)` bedeutet: greift zusätzlich nur, wenn der
|
||||||
passende Projektschalter gesetzt ist (siehe Abschnitt *Je Projekt einstellbar*).
|
passende Projektschalter gesetzt ist (siehe Abschnitt *Je Projekt einstellbar*).
|
||||||
@@ -202,8 +207,9 @@ Bauherrn – einstellbar beim Anlegen und jederzeit unter *Projekt → Einstellu
|
|||||||
**Diese Schalter steuern ausschließlich die Sichtbarkeit von Bereichen, niemals den
|
**Diese Schalter steuern ausschließlich die Sichtbarkeit von Bereichen, niemals den
|
||||||
Datenumfang.** Ein Auftragnehmer sieht in jedem Fall nur Verträge, Aufmaße,
|
Datenumfang.** Ein Auftragnehmer sieht in jedem Fall nur Verträge, Aufmaße,
|
||||||
Stundenzettel und Rechnungen der eigenen Firma. Diese Schranke hängt allein an der
|
Stundenzettel und Rechnungen der eigenen Firma. Diese Schranke hängt allein an der
|
||||||
Rolle (`isExternalOnly()` in `src/lib/rbac.ts`) und ist durch keine Einstellung
|
**Seite des Bauvertrags**, die jede Rolle mitbringt (`Rechtelage.nurEigeneFirma` in
|
||||||
aufhebbar; die Datenbankabfragen filtern zusätzlich auf `companyId`.
|
`src/lib/rbac.ts`), und ist durch keine Einstellung aufhebbar; die Datenbankabfragen
|
||||||
|
filtern zusätzlich auf `companyId`.
|
||||||
|
|
||||||
Bewusst nicht geschaltet: Der Reiter **Beteiligte** zeigt allen Projektbeteiligten,
|
Bewusst nicht geschaltet: Der Reiter **Beteiligte** zeigt allen Projektbeteiligten,
|
||||||
welche Firmen und Personen mitwirken. Das entspricht Position B.I 0 der
|
welche Firmen und Personen mitwirken. Das entspricht Position B.I 0 der
|
||||||
@@ -238,7 +244,7 @@ Unter *Verwaltung* (nur für Zugänge mit Administrationsrecht), in vier Reitern
|
|||||||
|---|---|
|
|---|---|
|
||||||
| **Benutzer** | Zugänge anlegen, bearbeiten, sperren und entsperren, Kennwort zurücksetzen |
|
| **Benutzer** | Zugänge anlegen, bearbeiten, sperren und entsperren, Kennwort zurücksetzen |
|
||||||
| **Firmen** | Firmenstamm mit Art, Anschrift, Kreditorennummer und USt-IdNr. |
|
| **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 |
|
| **Rollen & Rechte** | Rolleneditor für eigene Rollen, Legende aller Rollen und die vollständige Rechtematrix |
|
||||||
| **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person |
|
| **Protokoll** | Alle Vorgänge mit Filter nach Aktion und Person |
|
||||||
|
|
||||||
**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt
|
**Kennwörter.** Beim Anlegen wird ein Startkennwort erzeugt und **genau einmal** angezeigt
|
||||||
@@ -260,6 +266,40 @@ Firmen mit bestehenden Verträgen lassen sich nicht stilllegen.
|
|||||||
*Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt
|
*Einstellungen → Beteiligte*. Das Administrationsrecht ist davon unabhängig und wirkt
|
||||||
projektübergreifend – entsprechend sparsam vergeben.
|
projektübergreifend – entsprechend sparsam vergeben.
|
||||||
|
|
||||||
|
### Eigene Rollen
|
||||||
|
|
||||||
|
Die **elf Standardrollen sind Teil des Programms** – sie bilden den Vorgabeprozess ab und
|
||||||
|
lassen sich weder ändern noch löschen. Prüfvermerke und Bescheinigungen berufen sich auf
|
||||||
|
sie; ein nachträglich verändertes Rollenverständnis würde die Nachvollziehbarkeit alter
|
||||||
|
Vorgänge zerstören.
|
||||||
|
|
||||||
|
Zusätzliche Rollen entstehen unter *Verwaltung → Rollen & Rechte → Rolle anlegen*:
|
||||||
|
|
||||||
|
1. **Vorlage wählen** (optional) – übernimmt Rechte und Seite einer Standardrolle als
|
||||||
|
Ausgangspunkt. Ohne Vorlage beginnt man bei null.
|
||||||
|
2. **Seite des Bauvertrags festlegen** – Auftraggeberseite (sieht das ganze Projekt) oder
|
||||||
|
Auftragnehmerseite bzw. Zuarbeit (sieht ausschließlich die eigene Firma). Das ist die
|
||||||
|
folgenreichste Angabe: Sie bestimmt die Datensicht, nicht die Rechteliste.
|
||||||
|
3. **Rechte ankreuzen** – gruppiert nach Modul, mit Sammelschaltern je Gruppe.
|
||||||
|
|
||||||
|
Typischer Anlass: eine Bauüberwachung, die das Bautagebuch führt und Stunden anerkennt,
|
||||||
|
aber die sachliche Richtigkeit nicht bescheinigen soll. Vorlage *Bauüberwacher Bahn*
|
||||||
|
wählen, das Recht *sachlich richtig bescheinigen* abwählen, fertig.
|
||||||
|
|
||||||
|
Eigene Rollen werden im Projekt unter *Einstellungen → Beteiligte* genauso vergeben wie
|
||||||
|
Standardrollen und erscheinen in allen Rechteübersichten mit dem Vermerk „eigene Rolle".
|
||||||
|
|
||||||
|
**Grenzen, die auch der Rolleneditor nicht überschreitet:**
|
||||||
|
|
||||||
|
- **Administrationsrechte** (Benutzerverwaltung, Firmenstamm, Protokoll) lassen sich nicht
|
||||||
|
über eine Rolle vergeben – sonst käme man über eine selbst gebaute Rolle an die
|
||||||
|
Benutzerverwaltung und damit an jede andere Schranke. Sie werden ausschließlich am Zugang
|
||||||
|
gesetzt.
|
||||||
|
- Eine Rolle **ohne jedes Recht** wird abgelehnt.
|
||||||
|
- Eine Rolle, die **noch vergeben ist**, lässt sich weder stilllegen noch löschen.
|
||||||
|
- Die Vier-Augen-Trennung bleibt: Wer *sachlich richtig* bescheinigt hat, kann dieselbe
|
||||||
|
Rechnung nicht *rechnerisch richtig* bescheinigen – unabhängig von der Rolle.
|
||||||
|
|
||||||
### 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:
|
||||||
|
|||||||
@@ -40,6 +40,34 @@ enum Role {
|
|||||||
LESER
|
LESER
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Auf welcher Seite des Bauvertrags eine Rolle steht. Bestimmt, ob die
|
||||||
|
/// Trägerin oder der Träger das ganze Projekt oder nur die eigene Firma sieht.
|
||||||
|
enum RolleSeite {
|
||||||
|
AG // Bauherrenseite – sieht das gesamte Projekt
|
||||||
|
AN // Auftragnehmerseite – sieht ausschließlich die eigene Firma
|
||||||
|
NEUTRAL // Zuarbeit, Prüfung, Mitlesen – ebenfalls auf die eigene Firma beschränkt
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Frei definierbare Rolle. Die elf Standardrollen stehen im Code
|
||||||
|
/// (src/lib/rbac.ts) und sind unveränderlich; hier entstehen zusätzliche
|
||||||
|
/// Rollen, wahlweise auf Grundlage einer Standardrolle als Vorlage.
|
||||||
|
model CustomRole {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
schluessel String @unique // technischer Name, z.B. "fbue-oberleitung"
|
||||||
|
name String
|
||||||
|
beschreibung String?
|
||||||
|
seite RolleSeite @default(NEUTRAL)
|
||||||
|
vorlage Role? // Standardrolle, von der abgeleitet wurde
|
||||||
|
rechte String[] @default([]) // Schlüssel aus PERMISSIONS
|
||||||
|
aktiv Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdById String?
|
||||||
|
createdBy User? @relation("CustomRoleAutor", fields: [createdById], references: [id])
|
||||||
|
|
||||||
|
mitglieder ProjectMember[]
|
||||||
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -111,6 +139,7 @@ model User {
|
|||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
slotResponsible ProjectDocumentSlot[] @relation("SlotResponsible")
|
slotResponsible ProjectDocumentSlot[] @relation("SlotResponsible")
|
||||||
tasks Task[] @relation("TaskAssignee")
|
tasks Task[] @relation("TaskAssignee")
|
||||||
|
eigeneRollen CustomRole[] @relation("CustomRoleAutor")
|
||||||
|
|
||||||
@@index([companyId])
|
@@index([companyId])
|
||||||
}
|
}
|
||||||
@@ -140,13 +169,20 @@ model ProjectMember {
|
|||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
role Role
|
|
||||||
|
// Entweder eine Standardrolle aus dem Code oder eine selbst angelegte.
|
||||||
|
// Genau eines von beiden ist gesetzt; darauf achtet die Anwendungsschicht.
|
||||||
|
role Role?
|
||||||
|
customRoleId String?
|
||||||
|
customRole CustomRole? @relation(fields: [customRoleId], references: [id])
|
||||||
|
|
||||||
// Fachbereiche, auf die sich diese Rolle beschränkt (leer = alle)
|
// Fachbereiche, auf die sich diese Rolle beschränkt (leer = alle)
|
||||||
fachbereiche String[] @default([])
|
fachbereiche String[] @default([])
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@unique([projectId, userId, role])
|
@@unique([projectId, userId, role, customRoleId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
|
@@index([customRoleId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireUser, hashPassword, verifyPassword } from "@/lib/auth";
|
import { requireUser, hashPassword, verifyPassword } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { ROLE_LABEL } from "@/lib/rbac";
|
import { rollenBezeichnung } from "@/lib/rbac";
|
||||||
import { datumZeit } from "@/lib/format";
|
import { datumZeit } from "@/lib/format";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -18,7 +18,7 @@ export default async function Profil({
|
|||||||
where: { id: session.id },
|
where: { id: session.id },
|
||||||
include: {
|
include: {
|
||||||
company: true,
|
company: true,
|
||||||
memberships: { include: { project: { select: { nummer: true, name: true } } } },
|
memberships: { include: { project: { select: { nummer: true, name: true } }, customRole: { select: { name: true } } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ export default async function Profil({
|
|||||||
<div className="text-slate-200">{m.project.name}</div>
|
<div className="text-slate-200">{m.project.name}</div>
|
||||||
<div className="text-[11px] text-db-muted">{m.project.nummer}</div>
|
<div className="text-[11px] text-db-muted">{m.project.nummer}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="td text-right text-sm text-slate-300">{ROLE_LABEL[m.role]}</td>
|
<td className="td text-right text-sm text-slate-300">{rollenBezeichnung(m)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { protokolliere } from "@/lib/notify";
|
import { protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
export async function eintragAnlegen(formData: FormData) {
|
export async function eintragAnlegen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "bautagebuch.schreiben");
|
rechte.pruefe("bautagebuch.schreiben");
|
||||||
|
|
||||||
const datum = new Date(String(formData.get("datum")));
|
const datum = new Date(String(formData.get("datum")));
|
||||||
const letzter = await prisma.siteDiaryEntry.findFirst({
|
const letzter = await prisma.siteDiaryEntry.findFirst({
|
||||||
@@ -52,8 +51,8 @@ export async function eintragAnlegen(formData: FormData) {
|
|||||||
export async function eintragAbschliessen(eintragId: string) {
|
export async function eintragAbschliessen(eintragId: string) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } });
|
const eintrag = await prisma.siteDiaryEntry.findUniqueOrThrow({ where: { id: eintragId } });
|
||||||
const roles = await rolesInProject(user.id, eintrag.projectId);
|
const rechte = await rechteImProjekt(user.id, eintrag.projectId);
|
||||||
assertCan(roles, "bautagebuch.schreiben");
|
rechte.pruefe("bautagebuch.schreiben");
|
||||||
if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt.");
|
if (eintrag.gesperrtAm) throw new Error("Der Eintrag ist bereits gesperrt.");
|
||||||
|
|
||||||
await prisma.siteDiaryEntry.update({
|
await prisma.siteDiaryEntry.update({
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { CloudSun, Lock, PenLine, Users } from "lucide-react";
|
import { CloudSun, Lock, PenLine, Users } from "lucide-react";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui";
|
import { StatusChip, Leer, Feld, KeinZugriff } from "@/components/ui";
|
||||||
import { datum, zahl } from "@/lib/format";
|
import { datum, zahl } from "@/lib/format";
|
||||||
import { NeuerEintrag } from "./NeuerEintrag";
|
import { NeuerEintrag } from "./NeuerEintrag";
|
||||||
@@ -11,11 +10,11 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Bautagebuch({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user, roles, optionen } = await projektKontext(id);
|
const { user, rechte } = await projektKontext(id);
|
||||||
if (!canInProject(roles, "bautagebuch.lesen", optionen))
|
if (!rechte.darf("bautagebuch.lesen"))
|
||||||
return <KeinZugriff bereich="Das Bautagebuch" />;
|
return <KeinZugriff bereich="Das Bautagebuch" />;
|
||||||
|
|
||||||
const darfSchreiben = can(roles, "bautagebuch.schreiben");
|
const darfSchreiben = rechte.darf("bautagebuch.schreiben");
|
||||||
|
|
||||||
const eintraege = await prisma.siteDiaryEntry.findMany({
|
const eintraege = await prisma.siteDiaryEntry.findMany({
|
||||||
where: { projectId: id },
|
where: { projectId: id },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { requireProjectAccess } from "@/lib/auth";
|
import { requireProjectAccess } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { ROLE_LABEL, ROLE_HINT, PERMISSIONS, can } from "@/lib/rbac";
|
import { ROLE_LABEL, ROLE_HINT, PERMISSIONS, PERMISSION_INFO, can, rollenBezeichnung, rechtelage, OPTIONEN_OFFEN } from "@/lib/rbac";
|
||||||
import { datum } from "@/lib/format";
|
import { datum } from "@/lib/format";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
|
|||||||
|
|
||||||
const mitglieder = await prisma.projectMember.findMany({
|
const mitglieder = await prisma.projectMember.findMany({
|
||||||
where: { projectId: id },
|
where: { projectId: id },
|
||||||
include: { user: { include: { company: true } } },
|
include: { user: { include: { company: true } }, customRole: true },
|
||||||
orderBy: [{ role: "asc" }],
|
orderBy: [{ role: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
|
|||||||
nachFirma.get(k)!.push(m);
|
nachFirma.get(k)!.push(m);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollenImProjekt = [...new Set(mitglieder.map((m) => m.role))] as Role[];
|
const rollenImProjekt = [...new Set(mitglieder.map((m) => m.role).filter((r): r is Role => !!r))];
|
||||||
|
const eigeneImProjekt = [...new Map(mitglieder.filter((m) => m.customRole).map((m) => [m.customRole!.id, m.customRole!])).values()];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -70,8 +71,15 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
|
|||||||
<div className="text-[11px] text-db-muted">{m.user.position}</div>
|
<div className="text-[11px] text-db-muted">{m.user.position}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="td">
|
<td className="td">
|
||||||
<span className="chip border-db-line bg-db-dark text-slate-300">{ROLE_LABEL[m.role]}</span>
|
<span className="chip border-db-line bg-db-dark text-slate-300">{rollenBezeichnung(m)}</span>
|
||||||
<div className="mt-0.5 text-[11px] text-db-muted">{ROLE_HINT[m.role]}</div>
|
{m.customRole && (
|
||||||
|
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">
|
||||||
|
eigene Rolle
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="mt-0.5 text-[11px] text-db-muted">
|
||||||
|
{m.role ? ROLE_HINT[m.role] : (m.customRole?.beschreibung ?? "Selbst angelegte Rolle.")}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="td text-xs text-slate-400">
|
<td className="td text-xs text-slate-400">
|
||||||
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
||||||
@@ -119,6 +127,23 @@ export default async function Beteiligte({ params }: { params: Promise<{ id: str
|
|||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
{eigeneImProjekt.map((e) => (
|
||||||
|
<tr key={e.id} className="row">
|
||||||
|
<td className="td text-slate-200">
|
||||||
|
{e.name}
|
||||||
|
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">eigen</span>
|
||||||
|
</td>
|
||||||
|
{RECHTE_ANZEIGE.map((r) => (
|
||||||
|
<td key={r.schluessel} className="td text-center">
|
||||||
|
{e.rechte.includes(r.schluessel) ? (
|
||||||
|
<span className="text-emerald-400">✓</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-700">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { protokolliere, benachrichtige } from "@/lib/notify";
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
import type { SlotStatus } from "@prisma/client";
|
import type { SlotStatus } from "@prisma/client";
|
||||||
|
|
||||||
@@ -17,8 +16,8 @@ export async function slotAendern(formData: FormData) {
|
|||||||
where: { id: slotId },
|
where: { id: slotId },
|
||||||
include: { requirement: { select: { titel: true } } },
|
include: { requirement: { select: { titel: true } } },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, slot.projectId);
|
const rechte = await rechteImProjekt(user.id, slot.projectId);
|
||||||
assertCan(roles, "ablage.steuern");
|
rechte.pruefe("ablage.steuern");
|
||||||
|
|
||||||
const daten: Record<string, unknown> = {};
|
const daten: Record<string, unknown> = {};
|
||||||
if (status) {
|
if (status) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui";
|
import { StatusChip, Fortschritt, AmpelPunkt, Kachel, KeinZugriff } from "@/components/ui";
|
||||||
import { datum, tageBis } from "@/lib/format";
|
import { datum, tageBis } from "@/lib/format";
|
||||||
import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen";
|
import { fristAmpel, UEBERGABE_LABEL, berechneAufbewahrung } from "@/lib/fristen";
|
||||||
@@ -25,11 +24,11 @@ export default async function Dokumentation({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const { roles, projekt, optionen } = await projektKontext(id);
|
const { rechte, projekt } = await projektKontext(id);
|
||||||
if (!canInProject(roles, "ablage.lesen", optionen))
|
if (!rechte.darf("ablage.lesen"))
|
||||||
return <KeinZugriff bereich="Der Dokumentationsstand" />;
|
return <KeinZugriff bereich="Der Dokumentationsstand" />;
|
||||||
|
|
||||||
const darfSteuern = can(roles, "ablage.steuern");
|
const darfSteuern = rechte.darf("ablage.steuern");
|
||||||
const akte = sp.akte ?? "P";
|
const akte = sp.akte ?? "P";
|
||||||
|
|
||||||
const alle = await prisma.projectDocumentSlot.findMany({
|
const alle = await prisma.projectDocumentSlot.findMany({
|
||||||
|
|||||||
@@ -2,9 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan, assertCanInProject, can } from "@/lib/rbac";
|
|
||||||
import { optionenAus } from "@/lib/projekte";
|
|
||||||
import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage";
|
import { speichereDatei, maxUploadBytes, mimeErlaubt } from "@/lib/storage";
|
||||||
import { protokolliere, benachrichtige } from "@/lib/notify";
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
|
import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
|
||||||
@@ -12,10 +10,10 @@ import type { DocumentStatus, Vertraulichkeit } from "@prisma/client";
|
|||||||
export async function dokumentHochladen(formData: FormData) {
|
export async function dokumentHochladen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
|
|
||||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
|
rechte.pruefe("dokument.hochladen");
|
||||||
const datei = formData.get("datei") as File | null;
|
const datei = formData.get("datei") as File | null;
|
||||||
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
||||||
if (datei.size > maxUploadBytes())
|
if (datei.size > maxUploadBytes())
|
||||||
@@ -107,9 +105,9 @@ export async function neueVersion(formData: FormData) {
|
|||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
include: { project: { select: { nummer: true } }, versions: { orderBy: { version: "desc" }, take: 1 } },
|
include: { project: { select: { nummer: true } }, versions: { orderBy: { version: "desc" }, take: 1 } },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, dokument.projectId);
|
const rechte = await rechteImProjekt(user.id, dokument.projectId);
|
||||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: dokument.projectId } });
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: dokument.projectId } });
|
||||||
assertCanInProject(roles, "dokument.hochladen", optionenAus(projekt));
|
rechte.pruefe("dokument.hochladen");
|
||||||
|
|
||||||
const datei = formData.get("datei") as File | null;
|
const datei = formData.get("datei") as File | null;
|
||||||
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
if (!datei || datei.size === 0) throw new Error("Es wurde keine Datei ausgewählt.");
|
||||||
@@ -147,8 +145,8 @@ export async function dokumentStatus(formData: FormData) {
|
|||||||
where: { id: documentId },
|
where: { id: documentId },
|
||||||
select: { projectId: true, titel: true, slotId: true, createdById: true },
|
select: { projectId: true, titel: true, slotId: true, createdById: true },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, dokument.projectId);
|
const rechte = await rechteImProjekt(user.id, dokument.projectId);
|
||||||
assertCan(roles, "dokument.freigeben");
|
rechte.pruefe("dokument.freigeben");
|
||||||
|
|
||||||
await prisma.document.update({ where: { id: documentId }, data: { status } });
|
await prisma.document.update({ where: { id: documentId }, data: { status } });
|
||||||
await prisma.documentReview.create({
|
await prisma.documentReview.create({
|
||||||
@@ -189,7 +187,3 @@ export async function dokumentStatus(formData: FormData) {
|
|||||||
revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
|
revalidatePath(`/projekte/${dokument.projectId}/dokumente`);
|
||||||
revalidatePath(`/projekte/${dokument.projectId}/dokumentation`);
|
revalidatePath(`/projekte/${dokument.projectId}/dokumentation`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function darfFreigeben(projectId: string, userId: string) {
|
|
||||||
return can(await rolesInProject(userId, projectId), "dokument.freigeben");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import { Download, FileText, Upload, ShieldAlert } from "lucide-react";
|
import { Download, FileText, Upload, ShieldAlert } from "lucide-react";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Leer } from "@/components/ui";
|
import { StatusChip, Leer } from "@/components/ui";
|
||||||
import { datum, datumZeit } from "@/lib/format";
|
import { datum, datumZeit } from "@/lib/format";
|
||||||
import { UploadPanel } from "./UploadPanel";
|
import { UploadPanel } from "./UploadPanel";
|
||||||
@@ -21,10 +20,10 @@ export default async function Dokumente({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
const darfHochladen = canInProject(roles, "dokument.hochladen", optionen);
|
const darfHochladen = rechte.darf("dokument.hochladen");
|
||||||
const darfFreigeben = can(roles, "dokument.freigeben");
|
const darfFreigeben = rechte.darf("dokument.freigeben");
|
||||||
const nurExtern = nurEigeneFirma;
|
const nurExtern = nurEigeneFirma;
|
||||||
|
|
||||||
const dokumente = await prisma.document.findMany({
|
const dokumente = await prisma.document.findMany({
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Mitglied = {
|
|||||||
firma: string | null;
|
firma: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
roleLabel: string;
|
roleLabel: string;
|
||||||
|
eigen: boolean;
|
||||||
fachbereiche: string[];
|
fachbereiche: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ export function Mitglieder({
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
mitglieder: Mitglied[];
|
mitglieder: Mitglied[];
|
||||||
personen: { id: string; name: string; position: string | null; firma: string | null }[];
|
personen: { id: string; name: string; position: string | null; firma: string | null }[];
|
||||||
rollen: { wert: string; label: string; hinweis: string }[];
|
rollen: { wert: string; label: string; hinweis: string; eigen: boolean; seite: string }[];
|
||||||
fachbereicheAuswahl: string[];
|
fachbereicheAuswahl: string[];
|
||||||
}) {
|
}) {
|
||||||
const [offen, setOffen] = useState(false);
|
const [offen, setOffen] = useState(false);
|
||||||
@@ -87,6 +88,7 @@ export function Mitglieder({
|
|||||||
{rollen.map((r) => (
|
{rollen.map((r) => (
|
||||||
<option key={r.wert} value={r.wert}>
|
<option key={r.wert} value={r.wert}>
|
||||||
{r.label}
|
{r.label}
|
||||||
|
{r.eigen ? " (eigene Rolle)" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -145,6 +147,9 @@ export function Mitglieder({
|
|||||||
</td>
|
</td>
|
||||||
<td className="td">
|
<td className="td">
|
||||||
<span className="chip border-db-line bg-db-dark text-slate-300">{m.roleLabel}</span>
|
<span className="chip border-db-line bg-db-dark text-slate-300">{m.roleLabel}</span>
|
||||||
|
{m.eigen && (
|
||||||
|
<span className="ml-1.5 chip border-db-cyan/40 bg-db-cyan/10 text-[10px] text-db-cyan">eigen</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="td text-xs text-slate-400">
|
<td className="td text-xs text-slate-400">
|
||||||
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
{m.fachbereiche.length ? m.fachbereiche.join(", ") : <span className="text-db-muted">alle</span>}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte";
|
import { slotsErzeugen, fristenNeuBerechnen, meilensteineAusTerminen } from "@/lib/projekte";
|
||||||
import { benachrichtige, protokolliere } from "@/lib/notify";
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
import type { ProjectStatus, Role } from "@prisma/client";
|
import type { ProjectStatus, Role } from "@prisma/client";
|
||||||
@@ -17,8 +16,8 @@ function datumOderNull(v: FormDataEntryValue | null): Date | null {
|
|||||||
export async function einstellungenSpeichern(formData: FormData) {
|
export async function einstellungenSpeichern(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "projekt.bearbeiten");
|
rechte.pruefe("projekt.bearbeiten");
|
||||||
|
|
||||||
const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
const vorher = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
const neu = {
|
const neu = {
|
||||||
@@ -48,8 +47,8 @@ export async function einstellungenSpeichern(formData: FormData) {
|
|||||||
export async function projektdatenSpeichern(formData: FormData) {
|
export async function projektdatenSpeichern(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "projekt.bearbeiten");
|
rechte.pruefe("projekt.bearbeiten");
|
||||||
|
|
||||||
const termine = {
|
const termine = {
|
||||||
startgespraech: datumOderNull(formData.get("startgespraech")),
|
startgespraech: datumOderNull(formData.get("startgespraech")),
|
||||||
@@ -97,19 +96,28 @@ export async function projektdatenSpeichern(formData: FormData) {
|
|||||||
export async function mitgliedHinzufuegen(formData: FormData) {
|
export async function mitgliedHinzufuegen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "projekt.mitglieder");
|
rechte.pruefe("projekt.mitglieder");
|
||||||
|
|
||||||
const userId = String(formData.get("userId"));
|
const userId = String(formData.get("userId"));
|
||||||
const role = String(formData.get("role")) as Role;
|
const auswahl = String(formData.get("role"));
|
||||||
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
|
const fachbereiche = formData.getAll("fachbereiche").map(String).filter(Boolean);
|
||||||
if (!userId) throw new Error("Bitte eine Person auswählen.");
|
if (!userId) throw new Error("Bitte eine Person auswählen.");
|
||||||
|
if (!auswahl) throw new Error("Bitte eine Rolle auswählen.");
|
||||||
|
|
||||||
await prisma.projectMember.upsert({
|
// Eigene Rollen kommen als "custom:<id>" aus dem Formular.
|
||||||
where: { projectId_userId_role: { projectId, userId, role } },
|
const eigen = auswahl.startsWith("custom:");
|
||||||
create: { projectId, userId, role, fachbereiche },
|
const role = eigen ? null : (auswahl as Role);
|
||||||
update: { fachbereiche },
|
const customRoleId = eigen ? auswahl.slice(7) : null;
|
||||||
|
|
||||||
|
const vorhanden = await prisma.projectMember.findFirst({
|
||||||
|
where: { projectId, userId, role, customRoleId },
|
||||||
});
|
});
|
||||||
|
if (vorhanden) {
|
||||||
|
await prisma.projectMember.update({ where: { id: vorhanden.id }, data: { fachbereiche } });
|
||||||
|
} else {
|
||||||
|
await prisma.projectMember.create({ data: { projectId, userId, role, customRoleId, fachbereiche } });
|
||||||
|
}
|
||||||
|
|
||||||
// In den allgemeinen Projektkanal aufnehmen
|
// In den allgemeinen Projektkanal aufnehmen
|
||||||
const kanal = await prisma.channel.findFirst({ where: { projectId, kind: "PROJEKT" } });
|
const kanal = await prisma.channel.findFirst({ where: { projectId, kind: "PROJEKT" } });
|
||||||
@@ -124,7 +132,7 @@ export async function mitgliedHinzufuegen(formData: FormData) {
|
|||||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: projectId } });
|
||||||
await protokolliere({
|
await protokolliere({
|
||||||
userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember",
|
userId: user.id, aktion: "MITGLIED_HINZU", entitaet: "ProjectMember",
|
||||||
entitaetId: userId, projectId, details: { role },
|
entitaetId: userId, projectId, details: { role: auswahl },
|
||||||
});
|
});
|
||||||
await benachrichtige({
|
await benachrichtige({
|
||||||
userIds: [userId],
|
userIds: [userId],
|
||||||
@@ -145,8 +153,8 @@ export async function mitgliedEntfernen(formData: FormData) {
|
|||||||
const memberId = String(formData.get("memberId"));
|
const memberId = String(formData.get("memberId"));
|
||||||
|
|
||||||
const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } });
|
const mitglied = await prisma.projectMember.findUniqueOrThrow({ where: { id: memberId } });
|
||||||
const roles = await rolesInProject(user.id, mitglied.projectId);
|
const rechte = await rechteImProjekt(user.id, mitglied.projectId);
|
||||||
assertCan(roles, "projekt.mitglieder");
|
rechte.pruefe("projekt.mitglieder");
|
||||||
|
|
||||||
// Die letzte Projektleitung darf nicht entfernt werden
|
// Die letzte Projektleitung darf nicht entfernt werden
|
||||||
if (mitglied.role === "PROJEKTLEITER") {
|
if (mitglied.role === "PROJEKTLEITER") {
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, ROLE_LABEL, ROLE_HINT, OPTION_INFO } from "@/lib/rbac";
|
import { OPTION_INFO, rollenBezeichnung } from "@/lib/rbac";
|
||||||
|
import { rollenAuswahl } from "@/lib/rollenauswahl";
|
||||||
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
||||||
import { KeinZugriff } from "@/components/ui";
|
import { KeinZugriff } from "@/components/ui";
|
||||||
import { EinstellungenFormular } from "./EinstellungenFormular";
|
import { EinstellungenFormular } from "./EinstellungenFormular";
|
||||||
import { ProjektdatenFormular } from "./ProjektdatenFormular";
|
import { ProjektdatenFormular } from "./ProjektdatenFormular";
|
||||||
import { Mitglieder } from "./Mitglieder";
|
import { Mitglieder } from "./Mitglieder";
|
||||||
import type { Role } from "@prisma/client";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Einstellungen({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { roles, projekt } = await projektKontext(id);
|
const { rechte, projekt } = await projektKontext(id);
|
||||||
if (!can(roles, "projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
|
if (!rechte.darf("projekt.bearbeiten")) return <KeinZugriff bereich="Die Projekteinstellungen" />;
|
||||||
|
|
||||||
const [mitglieder, personen, slotStat] = await Promise.all([
|
const [mitglieder, personen, slotStat] = await Promise.all([
|
||||||
prisma.projectMember.findMany({
|
prisma.projectMember.findMany({
|
||||||
where: { projectId: id },
|
where: { projectId: id },
|
||||||
include: { user: { select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } } } },
|
include: {
|
||||||
|
user: { select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true } } } },
|
||||||
|
customRole: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
orderBy: [{ role: "asc" }],
|
orderBy: [{ role: "asc" }],
|
||||||
}),
|
}),
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
@@ -30,6 +33,8 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
|
|||||||
prisma.projectDocumentSlot.count({ where: { projectId: id } }),
|
prisma.projectDocumentSlot.count({ where: { projectId: id } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const rollen = await rollenAuswahl();
|
||||||
|
|
||||||
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
|
const istBahnhof = projekt.bauherr === "DB_INFRAGO_BAHNHOF";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +71,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{can(roles, "projekt.mitglieder") && (
|
{rechte.darf("projekt.mitglieder") && (
|
||||||
<Mitglieder
|
<Mitglieder
|
||||||
projectId={id}
|
projectId={id}
|
||||||
mitglieder={mitglieder.map((m) => ({
|
mitglieder={mitglieder.map((m) => ({
|
||||||
@@ -75,8 +80,9 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
|
|||||||
name: m.user.name,
|
name: m.user.name,
|
||||||
position: m.user.position,
|
position: m.user.position,
|
||||||
firma: m.user.company?.shortName ?? m.user.company?.name ?? null,
|
firma: m.user.company?.shortName ?? m.user.company?.name ?? null,
|
||||||
role: m.role,
|
role: m.customRole ? `custom:${m.customRole.id}` : (m.role ?? ""),
|
||||||
roleLabel: ROLE_LABEL[m.role],
|
roleLabel: rollenBezeichnung(m),
|
||||||
|
eigen: !!m.customRole,
|
||||||
fachbereiche: m.fachbereiche,
|
fachbereiche: m.fachbereiche,
|
||||||
}))}
|
}))}
|
||||||
personen={personen.map((p) => ({
|
personen={personen.map((p) => ({
|
||||||
@@ -85,11 +91,7 @@ export default async function Einstellungen({ params }: { params: Promise<{ id:
|
|||||||
position: p.position,
|
position: p.position,
|
||||||
firma: p.company?.shortName ?? p.company?.name ?? null,
|
firma: p.company?.shortName ?? p.company?.name ?? null,
|
||||||
}))}
|
}))}
|
||||||
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({
|
rollen={rollen}
|
||||||
wert: r,
|
|
||||||
label: ROLE_LABEL[r],
|
|
||||||
hinweis: ROLE_HINT[r],
|
|
||||||
}))}
|
|
||||||
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
|
fachbereicheAuswahl={istBahnhof ? FACHBEREICHE_BAHNHOF : FACHBEREICHE_NETZ}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { benachrichtige, findeErwaehnungen } from "@/lib/notify";
|
import { benachrichtige, findeErwaehnungen } from "@/lib/notify";
|
||||||
|
|
||||||
export async function nachrichtSenden(formData: FormData) {
|
export async function nachrichtSenden(formData: FormData) {
|
||||||
@@ -18,8 +17,8 @@ export async function nachrichtSenden(formData: FormData) {
|
|||||||
});
|
});
|
||||||
if (!kanal.projectId) throw new Error("Kanal ohne Projektbezug.");
|
if (!kanal.projectId) throw new Error("Kanal ohne Projektbezug.");
|
||||||
|
|
||||||
const roles = await rolesInProject(user.id, kanal.projectId);
|
const rechte = await rechteImProjekt(user.id, kanal.projectId);
|
||||||
assertCan(roles, "chat.schreiben");
|
rechte.pruefe("chat.schreiben");
|
||||||
if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin)
|
if (!kanal.mitglieder.some((m) => m.userId === user.id) && !user.isAdmin)
|
||||||
throw new Error("Sie sind kein Mitglied dieses Kanals.");
|
throw new Error("Sie sind kein Mitglied dieses Kanals.");
|
||||||
|
|
||||||
@@ -74,8 +73,8 @@ export async function nachrichtSenden(formData: FormData) {
|
|||||||
export async function kanalAnlegen(formData: FormData) {
|
export async function kanalAnlegen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "chat.kanal.anlegen");
|
rechte.pruefe("chat.kanal.anlegen");
|
||||||
|
|
||||||
const privat = formData.get("privat") === "on";
|
const privat = formData.get("privat") === "on";
|
||||||
const mitgliedIds = formData.getAll("mitglieder").map(String);
|
const mitgliedIds = formData.getAll("mitglieder").map(String);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import { Hash, Lock, Users } from "lucide-react";
|
import { Hash, Lock, Users } from "lucide-react";
|
||||||
import { requireProjectAccess } from "@/lib/auth";
|
import { requireProjectAccess } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can } from "@/lib/rbac";
|
|
||||||
import { relativ } from "@/lib/format";
|
import { relativ } from "@/lib/format";
|
||||||
import { Nachrichtenfeld } from "./Nachrichtenfeld";
|
import { Nachrichtenfeld } from "./Nachrichtenfeld";
|
||||||
import { NeuerKanal } from "./NeuerKanal";
|
import { NeuerKanal } from "./NeuerKanal";
|
||||||
@@ -19,7 +18,7 @@ export default async function Kommunikation({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const { user, roles } = await requireProjectAccess(id);
|
const { user, rechte } = await requireProjectAccess(id);
|
||||||
|
|
||||||
const kanaele = await prisma.channel.findMany({
|
const kanaele = await prisma.channel.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -56,7 +55,7 @@ export default async function Kommunikation({
|
|||||||
return (
|
return (
|
||||||
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
<div className="grid gap-5 lg:grid-cols-[260px_1fr]">
|
||||||
<aside className="space-y-3">
|
<aside className="space-y-3">
|
||||||
{can(roles, "chat.kanal.anlegen") && <NeuerKanal projectId={id} personen={personen} />}
|
{rechte.darf("chat.kanal.anlegen") && <NeuerKanal projectId={id} personen={personen} />}
|
||||||
|
|
||||||
<nav className="card overflow-hidden">
|
<nav className="card overflow-hidden">
|
||||||
<h2 className="border-b border-db-line px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-db-muted">
|
<h2 className="border-b border-db-line px-3 py-2.5 text-xs font-semibold uppercase tracking-wider text-db-muted">
|
||||||
@@ -157,7 +156,7 @@ export default async function Kommunikation({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{can(roles, "chat.schreiben") && <Nachrichtenfeld channelId={aktiv.id} kanalName={aktiv.name} />}
|
{rechte.darf("chat.schreiben") && <Nachrichtenfeld channelId={aktiv.id} kanalName={aktiv.name} />}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="p-10 text-center text-sm text-db-muted">Für dieses Projekt ist noch kein Kanal angelegt.</p>
|
<p className="p-10 text-center text-sm text-db-muted">Für dieses Projekt ist noch kein Kanal angelegt.</p>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { projektKontext } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { TabLink } from "@/components/NavLink";
|
import { TabLink } from "@/components/NavLink";
|
||||||
import { StatusChip } from "@/components/ui";
|
import { StatusChip } from "@/components/ui";
|
||||||
import { can, canInProject } from "@/lib/rbac";
|
|
||||||
import { datum } from "@/lib/format";
|
import { datum } from "@/lib/format";
|
||||||
|
|
||||||
export default async function ProjektLayout({
|
export default async function ProjektLayout({
|
||||||
@@ -13,12 +12,12 @@ export default async function ProjektLayout({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { roles, projekt, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { rechte, projekt, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
const darf = rechte.darf;
|
||||||
|
|
||||||
const abschnitte = await prisma.projectSection.findMany({ where: { projectId: id }, take: 1 });
|
const abschnitte = await prisma.projectSection.findMany({ where: { projectId: id }, take: 1 });
|
||||||
const a = abschnitte[0];
|
const a = abschnitte[0];
|
||||||
const base = `/projekte/${id}`;
|
const base = `/projekte/${id}`;
|
||||||
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
|
|
||||||
|
|
||||||
const tabs: { href: string; label: string; sichtbar: boolean; exact?: boolean }[] = [
|
const tabs: { href: string; label: string; sichtbar: boolean; exact?: boolean }[] = [
|
||||||
{ href: base, label: "Übersicht", sichtbar: true, exact: true },
|
{ href: base, label: "Übersicht", sichtbar: true, exact: true },
|
||||||
@@ -31,7 +30,7 @@ export default async function ProjektLayout({
|
|||||||
{ href: `${base}/rechnungen`, label: "Rechnungsprüfung", sichtbar: darf("rechnung.lesen") },
|
{ href: `${base}/rechnungen`, label: "Rechnungsprüfung", sichtbar: darf("rechnung.lesen") },
|
||||||
{ href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true },
|
{ href: `${base}/kommunikation`, label: "Kommunikation", sichtbar: true },
|
||||||
{ href: `${base}/beteiligte`, label: "Beteiligte", sichtbar: true },
|
{ href: `${base}/beteiligte`, label: "Beteiligte", sichtbar: true },
|
||||||
{ href: `${base}/einstellungen`, label: "Einstellungen", sichtbar: can(roles, "projekt.bearbeiten") },
|
{ href: `${base}/einstellungen`, label: "Einstellungen", sichtbar: rechte.darf("projekt.bearbeiten") },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { benachrichtige, protokolliere } from "@/lib/notify";
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
import type { IssueStatus, IssueType, Severity } from "@prisma/client";
|
import type { IssueStatus, IssueType, Severity } from "@prisma/client";
|
||||||
|
|
||||||
export async function meldungAnlegen(formData: FormData) {
|
export async function meldungAnlegen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "vorgang.anlegen");
|
rechte.pruefe("vorgang.anlegen");
|
||||||
|
|
||||||
const letzte = await prisma.issue.findFirst({
|
const letzte = await prisma.issue.findFirst({
|
||||||
where: { projectId },
|
where: { projectId },
|
||||||
@@ -68,8 +67,8 @@ export async function meldungAnlegen(formData: FormData) {
|
|||||||
export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) {
|
export async function meldungStatus(issueId: string, status: IssueStatus, kommentar?: string) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } });
|
const vorgang = await prisma.issue.findUniqueOrThrow({ where: { id: issueId } });
|
||||||
const roles = await rolesInProject(user.id, vorgang.projectId);
|
const rechte = await rechteImProjekt(user.id, vorgang.projectId);
|
||||||
assertCan(roles, "vorgang.bearbeiten");
|
rechte.pruefe("vorgang.bearbeiten");
|
||||||
|
|
||||||
await prisma.issue.update({
|
await prisma.issue.update({
|
||||||
where: { id: issueId },
|
where: { id: issueId },
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Leer, Kachel } from "@/components/ui";
|
import { StatusChip, Leer, Kachel } from "@/components/ui";
|
||||||
import { datum, eur, tageBis } from "@/lib/format";
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
import { NeueMeldung } from "./NeueMeldung";
|
import { NeueMeldung } from "./NeueMeldung";
|
||||||
@@ -31,7 +30,7 @@ export default async function Meldungen({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const { user, roles, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
|
||||||
const [vorgaenge, firmen, mitglieder, vertraege] = await Promise.all([
|
const [vorgaenge, firmen, mitglieder, vertraege] = await Promise.all([
|
||||||
prisma.issue.findMany({
|
prisma.issue.findMany({
|
||||||
@@ -93,7 +92,7 @@ export default async function Meldungen({
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
{can(roles, "vorgang.anlegen") && (
|
{rechte.darf("vorgang.anlegen") && (
|
||||||
<NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} />
|
<NeueMeldung projectId={id} firmen={firmen} personen={personen} vertraege={vertraege} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +148,7 @@ export default async function Meldungen({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
{can(roles, "vorgang.bearbeiten") ? (
|
{rechte.darf("vorgang.bearbeiten") ? (
|
||||||
<StatusWechsel issueId={v.id} status={v.status} />
|
<StatusWechsel issueId={v.id} status={v.status} />
|
||||||
) : (
|
) : (
|
||||||
<StatusChip status={v.status} />
|
<StatusChip status={v.status} />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { canInProject } from "@/lib/rbac";
|
|
||||||
import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui";
|
import { Kachel, StatusChip, Fortschritt, Feld } from "@/components/ui";
|
||||||
import { datum, eur, tageBis } from "@/lib/format";
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
import { CheckCircle2, Circle, CircleDot } from "lucide-react";
|
import { CheckCircle2, Circle, CircleDot } from "lucide-react";
|
||||||
@@ -17,9 +16,9 @@ export default async function ProjektUebersicht({
|
|||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { angelegt } = await searchParams;
|
const { angelegt } = await searchParams;
|
||||||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
|
const darf = rechte.darf;
|
||||||
|
|
||||||
const darf = (p: Parameters<typeof canInProject>[1]) => canInProject(roles, p, optionen);
|
|
||||||
const zeigeVertraege = darf("vertrag.lesen");
|
const zeigeVertraege = darf("vertrag.lesen");
|
||||||
const zeigeDokumentation = darf("ablage.lesen");
|
const zeigeDokumentation = darf("ablage.lesen");
|
||||||
const zeigeTagebuch = darf("bautagebuch.lesen");
|
const zeigeTagebuch = darf("bautagebuch.lesen");
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { notFound } from "next/navigation";
|
|||||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react";
|
import { AlertTriangle, ArrowLeft, CheckCircle2, Info } from "lucide-react";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui";
|
import { StatusChip, Feld, Kachel, KeinZugriff } from "@/components/ui";
|
||||||
import { datum, datumZeit, eur, zahl } from "@/lib/format";
|
import { datum, datumZeit, eur, zahl } from "@/lib/format";
|
||||||
import { FINDING_LABEL } from "@/lib/rechnungspruefung";
|
import { FINDING_LABEL } from "@/lib/rechnungspruefung";
|
||||||
@@ -14,8 +13,8 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function RechnungDetail({ params }: { params: Promise<{ id: string; rid: string }> }) {
|
export default async function RechnungDetail({ params }: { params: Promise<{ id: string; rid: string }> }) {
|
||||||
const { id, rid } = await params;
|
const { id, rid } = await params;
|
||||||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
if (!canInProject(roles, "rechnung.lesen", optionen))
|
if (!rechte.darf("rechnung.lesen"))
|
||||||
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
||||||
|
|
||||||
const r = await prisma.invoice.findUnique({
|
const r = await prisma.invoice.findUnique({
|
||||||
@@ -122,10 +121,10 @@ export default async function RechnungDetail({ params }: { params: Promise<{ id:
|
|||||||
vorschlagKuerzung={Number(r.kuerzungNetto)}
|
vorschlagKuerzung={Number(r.kuerzungNetto)}
|
||||||
netto={Number(r.nettoBetrag)}
|
netto={Number(r.nettoBetrag)}
|
||||||
// Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter
|
// Prüfen und Bescheinigen hängen am Rollenrecht, nicht am Projektschalter
|
||||||
darfPruefen={can(roles, "rechnung.lesen")}
|
darfPruefen={rechte.darf("rechnung.lesen")}
|
||||||
darfSachlich={can(roles, "rechnung.sachlich")}
|
darfSachlich={rechte.darf("rechnung.sachlich")}
|
||||||
darfRechnerisch={can(roles, "rechnung.rechnerisch") && r.sachlichById !== user.id}
|
darfRechnerisch={rechte.darf("rechnung.rechnerisch") && r.sachlichById !== user.id}
|
||||||
darfFreigeben={can(roles, "rechnung.freigeben")}
|
darfFreigeben={rechte.darf("rechnung.freigeben")}
|
||||||
historie={{
|
historie={{
|
||||||
sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null,
|
sachlich: r.sachlichBy ? `${r.sachlichBy.name}, ${datumZeit(r.sachlichAm)}` : null,
|
||||||
rechnerisch: r.rechnerischBy ? `${r.rechnerischBy.name}, ${datumZeit(r.rechnerischAm)}` : null,
|
rechnerisch: r.rechnerischBy ? `${r.rechnerischBy.name}, ${datumZeit(r.rechnerischAm)}` : null,
|
||||||
@@ -189,7 +188,7 @@ export default async function RechnungDetail({ params }: { params: Promise<{ id:
|
|||||||
{eur(b.differenz)}
|
{eur(b.differenz)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{!b.erledigt && can(roles, "rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
|
{!b.erledigt && rechte.darf("rechnung.sachlich") && <BefundErledigen findingId={b.id} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -2,16 +2,15 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { pruefeRechnung } from "@/lib/rechnungspruefung";
|
import { pruefeRechnung } from "@/lib/rechnungspruefung";
|
||||||
import { benachrichtige, protokolliere } from "@/lib/notify";
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
export async function pruefungStarten(invoiceId: string) {
|
export async function pruefungStarten(invoiceId: string) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
const roles = await rolesInProject(user.id, rechnung.projectId);
|
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
|
||||||
assertCan(roles, "rechnung.lesen");
|
rechte.pruefe("rechnung.lesen");
|
||||||
|
|
||||||
const bericht = await pruefeRechnung(invoiceId);
|
const bericht = await pruefeRechnung(invoiceId);
|
||||||
await protokolliere({
|
await protokolliere({
|
||||||
@@ -30,8 +29,8 @@ export async function sachlichRichtig(formData: FormData) {
|
|||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } },
|
include: { befunde: { where: { erledigt: false, schwere: "KRITISCH" } } },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, rechnung.projectId);
|
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
|
||||||
assertCan(roles, "rechnung.sachlich");
|
rechte.pruefe("rechnung.sachlich");
|
||||||
|
|
||||||
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } });
|
const projekt = await prisma.project.findUniqueOrThrow({ where: { id: rechnung.projectId } });
|
||||||
const ignorieren = formData.get("trotzBefunden") === "on";
|
const ignorieren = formData.get("trotzBefunden") === "on";
|
||||||
@@ -95,8 +94,8 @@ export async function rechnerischRichtig(formData: FormData) {
|
|||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const invoiceId = String(formData.get("invoiceId"));
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
const roles = await rolesInProject(user.id, rechnung.projectId);
|
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
|
||||||
assertCan(roles, "rechnung.rechnerisch");
|
rechte.pruefe("rechnung.rechnerisch");
|
||||||
|
|
||||||
if (rechnung.status !== "SACHLICH_RICHTIG")
|
if (rechnung.status !== "SACHLICH_RICHTIG")
|
||||||
throw new Error("Die sachliche Richtigkeit muss zuerst durch die Bauüberwachung bescheinigt werden.");
|
throw new Error("Die sachliche Richtigkeit muss zuerst durch die Bauüberwachung bescheinigt werden.");
|
||||||
@@ -141,8 +140,8 @@ export async function zahlungFreigeben(formData: FormData) {
|
|||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const invoiceId = String(formData.get("invoiceId"));
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
const roles = await rolesInProject(user.id, rechnung.projectId);
|
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
|
||||||
assertCan(roles, "rechnung.freigeben");
|
rechte.pruefe("rechnung.freigeben");
|
||||||
|
|
||||||
if (rechnung.status !== "RECHNERISCH_RICHTIG")
|
if (rechnung.status !== "RECHNERISCH_RICHTIG")
|
||||||
throw new Error("Vor der Zahlungsfreigabe müssen sachliche und rechnerische Prüfung abgeschlossen sein.");
|
throw new Error("Vor der Zahlungsfreigabe müssen sachliche und rechnerische Prüfung abgeschlossen sein.");
|
||||||
@@ -162,8 +161,8 @@ export async function zurueckweisen(formData: FormData) {
|
|||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const invoiceId = String(formData.get("invoiceId"));
|
const invoiceId = String(formData.get("invoiceId"));
|
||||||
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
const rechnung = await prisma.invoice.findUniqueOrThrow({ where: { id: invoiceId } });
|
||||||
const roles = await rolesInProject(user.id, rechnung.projectId);
|
const rechte = await rechteImProjekt(user.id, rechnung.projectId);
|
||||||
assertCan(roles, "rechnung.sachlich");
|
rechte.pruefe("rechnung.sachlich");
|
||||||
|
|
||||||
await prisma.invoice.update({
|
await prisma.invoice.update({
|
||||||
where: { id: invoiceId },
|
where: { id: invoiceId },
|
||||||
@@ -184,8 +183,8 @@ export async function befundErledigen(formData: FormData) {
|
|||||||
where: { id: findingId },
|
where: { id: findingId },
|
||||||
include: { invoice: { select: { projectId: true, id: true } } },
|
include: { invoice: { select: { projectId: true, id: true } } },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, befund.invoice.projectId);
|
const rechte = await rechteImProjekt(user.id, befund.invoice.projectId);
|
||||||
assertCan(roles, "rechnung.sachlich");
|
rechte.pruefe("rechnung.sachlich");
|
||||||
|
|
||||||
await prisma.invoiceFinding.update({
|
await prisma.invoiceFinding.update({
|
||||||
where: { id: findingId },
|
where: { id: findingId },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
|||||||
import { AlertTriangle } from "lucide-react";
|
import { AlertTriangle } from "lucide-react";
|
||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
||||||
import { datum, eur } from "@/lib/format";
|
import { datum, eur } from "@/lib/format";
|
||||||
|
|
||||||
@@ -10,8 +9,8 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Rechnungen({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
if (!canInProject(roles, "rechnung.lesen", optionen))
|
if (!rechte.darf("rechnung.lesen"))
|
||||||
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
return <KeinZugriff bereich="Die Rechnungsprüfung" />;
|
||||||
|
|
||||||
const rechnungen = await prisma.invoice.findMany({
|
const rechnungen = await prisma.invoice.findMany({
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser, rolesInProject } from "@/lib/auth";
|
import { requireUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { assertCan } from "@/lib/rbac";
|
|
||||||
import { benachrichtige, protokolliere } from "@/lib/notify";
|
import { benachrichtige, protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,8 +18,8 @@ export async function zettelPruefen(formData: FormData) {
|
|||||||
where: { id: timeSheetId },
|
where: { id: timeSheetId },
|
||||||
include: { zeilen: true },
|
include: { zeilen: true },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, zettel.projectId);
|
const rechte = await rechteImProjekt(user.id, zettel.projectId);
|
||||||
assertCan(roles, "stunden.pruefen");
|
rechte.pruefe("stunden.pruefen");
|
||||||
|
|
||||||
let abweichung = false;
|
let abweichung = false;
|
||||||
for (const z of zettel.zeilen) {
|
for (const z of zettel.zeilen) {
|
||||||
@@ -71,8 +70,8 @@ export async function aufmassPruefen(formData: FormData) {
|
|||||||
where: { id: measurementId },
|
where: { id: measurementId },
|
||||||
include: { zeilen: true },
|
include: { zeilen: true },
|
||||||
});
|
});
|
||||||
const roles = await rolesInProject(user.id, aufmass.projectId);
|
const rechte = await rechteImProjekt(user.id, aufmass.projectId);
|
||||||
assertCan(roles, "aufmass.pruefen");
|
rechte.pruefe("aufmass.pruefen");
|
||||||
|
|
||||||
let abweichung = false;
|
let abweichung = false;
|
||||||
for (const z of aufmass.zeilen) {
|
for (const z of aufmass.zeilen) {
|
||||||
@@ -104,8 +103,8 @@ export async function aufmassPruefen(formData: FormData) {
|
|||||||
export async function zettelEinreichen(formData: FormData) {
|
export async function zettelEinreichen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const projectId = String(formData.get("projectId"));
|
const projectId = String(formData.get("projectId"));
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
assertCan(roles, "stunden.einreichen");
|
rechte.pruefe("stunden.einreichen");
|
||||||
|
|
||||||
const contractId = String(formData.get("contractId"));
|
const contractId = String(formData.get("contractId"));
|
||||||
const anzahl = await prisma.timeSheet.count({ where: { contractId } });
|
const anzahl = await prisma.timeSheet.count({ where: { contractId } });
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Kachel, Leer } from "@/components/ui";
|
import { StatusChip, Kachel, Leer } from "@/components/ui";
|
||||||
import { datum, eur, zahl } from "@/lib/format";
|
import { datum, eur, zahl } from "@/lib/format";
|
||||||
import { ZettelPruefung } from "./ZettelPruefung";
|
import { ZettelPruefung } from "./ZettelPruefung";
|
||||||
@@ -22,9 +21,9 @@ const ART_LABEL: Record<string, string> = {
|
|||||||
|
|
||||||
export default async function Stunden({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Stunden({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user, roles, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
const darfPruefen = can(roles, "stunden.pruefen");
|
const darfPruefen = rechte.darf("stunden.pruefen");
|
||||||
const darfEinreichen = can(roles, "stunden.einreichen");
|
const darfEinreichen = rechte.darf("stunden.einreichen");
|
||||||
|
|
||||||
// Datenauswahl hängt ausschließlich an der Rolle – ein Projektschalter
|
// Datenauswahl hängt ausschließlich an der Rolle – ein Projektschalter
|
||||||
// darf niemals fremde Firmendaten sichtbar machen.
|
// darf niemals fremde Firmendaten sichtbar machen.
|
||||||
@@ -254,7 +253,7 @@ export default async function Stunden({ params }: { params: Promise<{ id: string
|
|||||||
</header>
|
</header>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{a.bemerkung && <p className="mb-2 text-xs text-db-muted">{a.bemerkung}</p>}
|
{a.bemerkung && <p className="mb-2 text-xs text-db-muted">{a.bemerkung}</p>}
|
||||||
{can(roles, "aufmass.pruefen") && a.status === "EINGEREICHT" ? (
|
{rechte.darf("aufmass.pruefen") && a.status === "EINGEREICHT" ? (
|
||||||
<AufmassPruefung
|
<AufmassPruefung
|
||||||
measurementId={a.id}
|
measurementId={a.id}
|
||||||
zeilen={a.zeilen.map((z) => ({
|
zeilen={a.zeilen.map((z) => ({
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { projektKontext } from "@/lib/auth";
|
import { projektKontext } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { canInProject } from "@/lib/rbac";
|
|
||||||
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
import { StatusChip, Kachel, Leer, KeinZugriff } from "@/components/ui";
|
||||||
import { datum, eur, zahl } from "@/lib/format";
|
import { datum, eur, zahl } from "@/lib/format";
|
||||||
|
|
||||||
@@ -8,8 +7,8 @@ export const dynamic = "force-dynamic";
|
|||||||
|
|
||||||
export default async function Vertraege({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Vertraege({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { user, roles, optionen, nurEigeneFirma } = await projektKontext(id);
|
const { user, rechte, nurEigeneFirma } = await projektKontext(id);
|
||||||
if (!canInProject(roles, "vertrag.lesen", optionen))
|
if (!rechte.darf("vertrag.lesen"))
|
||||||
return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />;
|
return <KeinZugriff bereich="Der Bereich Verträge & Nachträge" />;
|
||||||
|
|
||||||
const vertraege = await prisma.contract.findMany({
|
const vertraege = await prisma.contract.findMany({
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function ProjektFormular({
|
|||||||
eigeneId,
|
eigeneId,
|
||||||
}: {
|
}: {
|
||||||
personen: Person[];
|
personen: Person[];
|
||||||
rollen: { wert: string; label: string }[];
|
rollen: { wert: string; label: string; hinweis: string; eigen: boolean; seite: string }[];
|
||||||
fachbereicheNetz: string[];
|
fachbereicheNetz: string[];
|
||||||
fachbereicheBahnhof: string[];
|
fachbereicheBahnhof: string[];
|
||||||
masken: { netz: number[]; bahnhof: number[] };
|
masken: { netz: number[]; bahnhof: number[] };
|
||||||
@@ -290,6 +290,7 @@ export function ProjektFormular({
|
|||||||
{rollen.map((r) => (
|
{rollen.map((r) => (
|
||||||
<option key={r.wert} value={r.wert}>
|
<option key={r.wert} value={r.wert}>
|
||||||
{r.label}
|
{r.label}
|
||||||
|
{r.eigen ? " (eigene Rolle)" : ""}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser, darfProjekteAnlegen } from "@/lib/auth";
|
||||||
import { can } from "@/lib/rbac";
|
|
||||||
import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte";
|
import { slotsErzeugen, meilensteineAusTerminen } from "@/lib/projekte";
|
||||||
import { protokolliere, benachrichtige } from "@/lib/notify";
|
import { protokolliere, benachrichtige } from "@/lib/notify";
|
||||||
import type { Bauherr, ProjectStatus, Role } from "@prisma/client";
|
import type { Bauherr, ProjectStatus, Role } from "@prisma/client";
|
||||||
@@ -17,14 +16,7 @@ function datumOderNull(v: FormDataEntryValue | null): Date | null {
|
|||||||
export async function projektAnlegen(formData: FormData) {
|
export async function projektAnlegen(formData: FormData) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
|
||||||
// Das Recht ist global – wer irgendwo Projektleitung ist, darf anlegen.
|
if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) {
|
||||||
const rollen = await prisma.projectMember.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: { role: true },
|
|
||||||
});
|
|
||||||
const globaleRollen: Role[] = [...new Set(rollen.map((r) => r.role))];
|
|
||||||
if (user.isAdmin) globaleRollen.push("ADMIN");
|
|
||||||
if (!can(globaleRollen, "projekt.anlegen")) {
|
|
||||||
throw new Error("Projekte dürfen nur Projektleitung und Administration anlegen.");
|
throw new Error("Projekte dürfen nur Projektleitung und Administration anlegen.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,32 +3,28 @@ import Link from "next/link";
|
|||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { can, ROLE_LABEL, OPTION_INFO, type ProjektOptionen } from "@/lib/rbac";
|
import { OPTION_INFO, type ProjektOptionen } from "@/lib/rbac";
|
||||||
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
import { FACHBEREICHE_NETZ, FACHBEREICHE_BAHNHOF } from "@/lib/fristen";
|
||||||
import { fachbereichsMasken } from "@/lib/projekte";
|
import { fachbereichsMasken } from "@/lib/projekte";
|
||||||
|
import { rollenAuswahl } from "@/lib/rollenauswahl";
|
||||||
|
import { darfProjekteAnlegen } from "@/lib/auth";
|
||||||
import { ProjektFormular } from "./ProjektFormular";
|
import { ProjektFormular } from "./ProjektFormular";
|
||||||
import type { Role } from "@prisma/client";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function NeuesProjekt() {
|
export default async function NeuesProjekt() {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
|
|
||||||
const eigene = await prisma.projectMember.findMany({
|
if (!(await darfProjekteAnlegen(user.id, user.isAdmin))) redirect("/projekte?fehler=kein-zugriff");
|
||||||
where: { userId: user.id },
|
|
||||||
select: { role: true },
|
|
||||||
});
|
|
||||||
const rollen: Role[] = [...new Set(eigene.map((r) => r.role))];
|
|
||||||
if (user.isAdmin) rollen.push("ADMIN");
|
|
||||||
if (!can(rollen, "projekt.anlegen")) redirect("/projekte?fehler=kein-zugriff");
|
|
||||||
|
|
||||||
const [personen, masken] = await Promise.all([
|
const [personen, masken, rollen] = await Promise.all([
|
||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
where: { active: true },
|
where: { active: true },
|
||||||
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } },
|
select: { id: true, name: true, position: true, company: { select: { shortName: true, name: true, type: true } } },
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
}),
|
}),
|
||||||
fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]),
|
fachbereichsMasken([...FACHBEREICHE_NETZ, ...FACHBEREICHE_BAHNHOF]),
|
||||||
|
rollenAuswahl(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const standardOptionen = Object.fromEntries(
|
const standardOptionen = Object.fromEntries(
|
||||||
@@ -58,7 +54,7 @@ export default async function NeuesProjekt() {
|
|||||||
firma: p.company?.shortName ?? p.company?.name ?? null,
|
firma: p.company?.shortName ?? p.company?.name ?? null,
|
||||||
extern: p.company?.type === "AUFTRAGNEHMER" || p.company?.type === "SICHERUNGSUNTERNEHMEN",
|
extern: p.company?.type === "AUFTRAGNEHMER" || p.company?.type === "SICHERUNGSUNTERNEHMEN",
|
||||||
}))}
|
}))}
|
||||||
rollen={(Object.keys(ROLE_LABEL) as Role[]).map((r) => ({ wert: r, label: ROLE_LABEL[r] }))}
|
rollen={rollen}
|
||||||
fachbereicheNetz={FACHBEREICHE_NETZ}
|
fachbereicheNetz={FACHBEREICHE_NETZ}
|
||||||
fachbereicheBahnhof={FACHBEREICHE_BAHNHOF}
|
fachbereicheBahnhof={FACHBEREICHE_BAHNHOF}
|
||||||
masken={masken}
|
masken={masken}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { StatusChip, Fortschritt, Leer } from "@/components/ui";
|
import { StatusChip, Fortschritt, Leer } from "@/components/ui";
|
||||||
import { datum, eur } from "@/lib/format";
|
import { datum, eur } from "@/lib/format";
|
||||||
import { ROLE_LABEL, can } from "@/lib/rbac";
|
import { rollenBezeichnung } from "@/lib/rbac";
|
||||||
import type { Role } from "@prisma/client";
|
import { darfProjekteAnlegen } from "@/lib/auth";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
|
|||||||
const projekte = await prisma.project.findMany({
|
const projekte = await prisma.project.findMany({
|
||||||
where: user.isAdmin ? {} : { members: { some: { userId: user.id } } },
|
where: user.isAdmin ? {} : { members: { some: { userId: user.id } } },
|
||||||
include: {
|
include: {
|
||||||
members: { where: { userId: user.id }, select: { role: true } },
|
members: { where: { userId: user.id }, select: { role: true, customRole: { select: { name: true } } } },
|
||||||
abschnitte: { take: 1 },
|
abschnitte: { take: 1 },
|
||||||
_count: { select: { documents: true, issues: true, contracts: true } },
|
_count: { select: { documents: true, issues: true, contracts: true } },
|
||||||
},
|
},
|
||||||
@@ -35,12 +35,9 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
|
|||||||
where: { projectId: { in: projekte.map((p) => p.id) } },
|
where: { projectId: { in: projekte.map((p) => p.id) } },
|
||||||
_count: true,
|
_count: true,
|
||||||
});
|
});
|
||||||
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist, darf anlegen.
|
// Das Anlagerecht ist nicht projektbezogen: wer irgendwo Projektleitung ist
|
||||||
const eigeneRollen: Role[] = [
|
// – über eine Standardrolle oder eine eigene Rolle – darf anlegen.
|
||||||
...new Set(projekte.flatMap((p) => p.members.map((m) => m.role))),
|
const darfAnlegen = await darfProjekteAnlegen(user.id, user.isAdmin);
|
||||||
...(user.isAdmin ? (["ADMIN"] as Role[]) : []),
|
|
||||||
];
|
|
||||||
const darfAnlegen = can(eigeneRollen, "projekt.anlegen");
|
|
||||||
|
|
||||||
const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>();
|
const grad = new Map<string, { gesamt: number; fertig: number; offen: number }>();
|
||||||
for (const s of slotStat) {
|
for (const s of slotStat) {
|
||||||
@@ -120,7 +117,7 @@ export default async function Projekte({ searchParams }: { searchParams: Promise
|
|||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between text-[11px] text-db-muted">
|
<div className="mt-3 flex items-center justify-between text-[11px] text-db-muted">
|
||||||
<span>{p.members.map((m) => ROLE_LABEL[m.role]).join(", ") || "Administration"}</span>
|
<span>{p.members.map((m) => rollenBezeichnung(m)).join(", ") || "Administration"}</span>
|
||||||
<span>IBN {datum(p.inbetriebnahme)}</span>
|
<span>IBN {datum(p.inbetriebnahme)}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui";
|
import { Kachel, StatusChip, Leer, Fortschritt } from "@/components/ui";
|
||||||
import { datum, eur, tageBis } from "@/lib/format";
|
import { datum, eur, tageBis } from "@/lib/format";
|
||||||
import { ROLE_LABEL, isExternalOnly } from "@/lib/rbac";
|
import { rollenBezeichnung, ROLE_SEITE } from "@/lib/rbac";
|
||||||
import type { Role } from "@prisma/client";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -14,7 +13,10 @@ export default async function Uebersicht() {
|
|||||||
|
|
||||||
const mitgliedschaften = await prisma.projectMember.findMany({
|
const mitgliedschaften = await prisma.projectMember.findMany({
|
||||||
where: { userId: user.id },
|
where: { userId: user.id },
|
||||||
include: { project: { select: { id: true, nummer: true, name: true, kurzname: true, status: true } } },
|
include: {
|
||||||
|
project: { select: { id: true, nummer: true, name: true, kurzname: true, status: true } },
|
||||||
|
customRole: { select: { name: true, seite: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const projektIds = [...new Set(mitgliedschaften.map((m) => m.projectId))];
|
const projektIds = [...new Set(mitgliedschaften.map((m) => m.projectId))];
|
||||||
|
|
||||||
@@ -22,15 +24,16 @@ export default async function Uebersicht() {
|
|||||||
return <Leer text="Ihnen ist derzeit kein Projekt zugeordnet. Bitte wenden Sie sich an die Projektleitung." />;
|
return <Leer text="Ihnen ist derzeit kein Projekt zugeordnet. Bitte wenden Sie sich an die Projektleitung." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Projekte trennen: in welchen ist der Nutzer auf der Bauherrenseite,
|
// Projekte trennen: in welchen steht der Nutzer auf der Bauherrenseite,
|
||||||
// in welchen Auftragnehmer? Für Letztere gilt überall die Firmenschranke.
|
// in welchen auf der Auftragnehmerseite? Für Letztere gilt die Firmenschranke.
|
||||||
const rollenJeProjekt = new Map<string, Role[]>();
|
// Standardrollen und eigene Rollen werden gleich behandelt – maßgeblich ist
|
||||||
|
// allein die hinterlegte Seite.
|
||||||
|
const aufAGSeite = new Map<string, boolean>();
|
||||||
for (const m of mitgliedschaften) {
|
for (const m of mitgliedschaften) {
|
||||||
rollenJeProjekt.set(m.projectId, [...(rollenJeProjekt.get(m.projectId) ?? []), m.role]);
|
const seite = m.role ? ROLE_SEITE[m.role] : (m.customRole?.seite ?? "NEUTRAL");
|
||||||
|
if (seite === "AG" || m.role === "ADMIN") aufAGSeite.set(m.projectId, true);
|
||||||
}
|
}
|
||||||
const intern = projektIds.filter(
|
const intern = projektIds.filter((pid) => user.isAdmin || aufAGSeite.get(pid) === true);
|
||||||
(pid) => user.isAdmin || !isExternalOnly(rollenJeProjekt.get(pid) ?? []),
|
|
||||||
);
|
|
||||||
const extern = projektIds.filter((pid) => !intern.includes(pid));
|
const extern = projektIds.filter((pid) => !intern.includes(pid));
|
||||||
const eigeneFirma = user.companyId ?? "__keine__";
|
const eigeneFirma = user.companyId ?? "__keine__";
|
||||||
|
|
||||||
@@ -124,14 +127,14 @@ export default async function Uebersicht() {
|
|||||||
proProjekt.set(s.projectId, e);
|
proProjekt.set(s.projectId, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rollen = [...new Set(mitgliedschaften.map((m) => m.role))];
|
const rollen = [...new Set(mitgliedschaften.map((m) => rollenBezeichnung(m)))];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold text-white">Guten Tag, {user.name.split(" ")[0]}.</h1>
|
<h1 className="text-xl font-semibold text-white">Guten Tag, {user.name.split(" ")[0]}.</h1>
|
||||||
<p className="mt-0.5 text-sm text-db-muted">
|
<p className="mt-0.5 text-sm text-db-muted">
|
||||||
{rollen.map((r) => ROLE_LABEL[r]).join(" · ")} · {projektIds.length}{" "}
|
{rollen.join(" · ")} · {projektIds.length}{" "}
|
||||||
{projektIds.length === 1 ? "Projekt" : "Projekte"}
|
{projektIds.length === 1 ? "Projekt" : "Projekte"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { requireUser } from "@/lib/auth";
|
import { requireUser } from "@/lib/auth";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { ROLE_LABEL } from "@/lib/rbac";
|
import { rollenBezeichnung } from "@/lib/rbac";
|
||||||
import { Benutzerliste } from "./Benutzerliste";
|
import { Benutzerliste } from "./Benutzerliste";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -12,7 +12,7 @@ export default async function BenutzerVerwaltung() {
|
|||||||
prisma.user.findMany({
|
prisma.user.findMany({
|
||||||
include: {
|
include: {
|
||||||
company: { select: { id: true, name: true, shortName: true } },
|
company: { select: { id: true, name: true, shortName: true } },
|
||||||
memberships: { select: { role: true, project: { select: { nummer: true } } } },
|
memberships: { select: { role: true, customRole: { select: { name: true } }, project: { select: { nummer: true } } } },
|
||||||
_count: { select: { sessions: true } },
|
_count: { select: { sessions: true } },
|
||||||
},
|
},
|
||||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||||
@@ -40,7 +40,7 @@ export default async function BenutzerVerwaltung() {
|
|||||||
firma: b.company?.shortName ?? b.company?.name ?? null,
|
firma: b.company?.shortName ?? b.company?.name ?? null,
|
||||||
lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null,
|
lastLoginAt: b.lastLoginAt ? b.lastLoginAt.toISOString() : null,
|
||||||
sitzungen: b._count.sessions,
|
sitzungen: b._count.sessions,
|
||||||
rollen: [...new Set(b.memberships.map((m) => ROLE_LABEL[m.role]))],
|
rollen: [...new Set(b.memberships.map((m) => rollenBezeichnung(m)))],
|
||||||
projekte: b.memberships.length,
|
projekte: b.memberships.length,
|
||||||
}))}
|
}))}
|
||||||
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))}
|
firmen={firmen.map((f) => ({ id: f.id, name: f.shortName ?? f.name, typ: f.type }))}
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Fragment, useEffect, useMemo, useState, useTransition } from "react";
|
||||||
|
import { Check, Copy, Lock, Pencil, Plus, Power, Shield, Trash2, X } from "lucide-react";
|
||||||
|
import { rolleSpeichern, rolleStatus, rolleLoeschen } from "./actions";
|
||||||
|
|
||||||
|
export type RechtInfo = { schluessel: string; label: string; gruppe: string; gesperrt: boolean };
|
||||||
|
export type EigeneRolleAnzeige = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
beschreibung: string | null;
|
||||||
|
seite: string;
|
||||||
|
vorlage: string | null;
|
||||||
|
vorlageLabel: string | null;
|
||||||
|
rechte: string[];
|
||||||
|
aktiv: boolean;
|
||||||
|
vergeben: number;
|
||||||
|
};
|
||||||
|
export type Vorlage = { wert: string; label: string; seite: string; rechte: string[] };
|
||||||
|
|
||||||
|
const SEITEN: [string, string, string][] = [
|
||||||
|
["AG", "Auftraggeberseite", "sieht das gesamte Projekt – für Rollen des Bauherrn"],
|
||||||
|
["AN", "Auftragnehmerseite", "sieht ausschließlich Daten der eigenen Firma"],
|
||||||
|
["NEUTRAL", "Zuarbeit / Mitlesen", "ebenfalls auf die eigene Firma beschränkt"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Rolleneditor({
|
||||||
|
eigene,
|
||||||
|
rechte,
|
||||||
|
gruppen,
|
||||||
|
vorlagen,
|
||||||
|
}: {
|
||||||
|
eigene: EigeneRolleAnzeige[];
|
||||||
|
rechte: RechtInfo[];
|
||||||
|
gruppen: string[];
|
||||||
|
vorlagen: Vorlage[];
|
||||||
|
}) {
|
||||||
|
const [modus, setModus] = useState<null | { art: "neu" } | { art: "bearbeiten"; rolle: EigeneRolleAnzeige }>(null);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const [pending, start] = useTransition();
|
||||||
|
|
||||||
|
const lauf = (fn: () => Promise<unknown>) =>
|
||||||
|
start(async () => {
|
||||||
|
setFehler(null);
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
setModus(null);
|
||||||
|
} catch (e) {
|
||||||
|
setFehler(e instanceof Error ? e.message : "Die Aktion war nicht möglich.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card overflow-hidden">
|
||||||
|
<header className="flex flex-wrap items-start justify-between gap-3 border-b border-db-line px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">Eigene Rollen</h2>
|
||||||
|
<p className="mt-0.5 max-w-2xl text-xs leading-relaxed text-db-muted">
|
||||||
|
Die elf Standardrollen sind Teil des Programms und lassen sich weder ändern noch
|
||||||
|
löschen – sie bilden den Vorgabeprozess ab und sind die Bezugsgröße, auf die sich
|
||||||
|
Prüfvermerke und Bescheinigungen berufen. Zusätzliche Rollen entstehen hier,
|
||||||
|
wahlweise auf Grundlage einer Standardrolle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setModus(modus?.art === "neu" ? null : { art: "neu" }); setFehler(null); }}
|
||||||
|
className="btn-primary shrink-0"
|
||||||
|
>
|
||||||
|
<Plus size={15} /> Rolle anlegen
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{fehler && (
|
||||||
|
<p className="border-b border-db-line bg-db-red/10 px-4 py-2.5 text-sm text-red-300">{fehler}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{modus?.art === "neu" && (
|
||||||
|
<RolleFormular
|
||||||
|
rechte={rechte}
|
||||||
|
gruppen={gruppen}
|
||||||
|
vorlagen={vorlagen}
|
||||||
|
pending={pending}
|
||||||
|
onAbbrechen={() => setModus(null)}
|
||||||
|
onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{eigene.length === 0 && modus?.art !== "neu" ? (
|
||||||
|
<p className="px-4 py-6 text-sm text-db-muted">
|
||||||
|
Noch keine eigene Rolle angelegt. Die Standardrollen decken den üblichen Ablauf ab –
|
||||||
|
eine eigene Rolle lohnt sich etwa für eine Bauüberwachung ohne Bescheinigungsrecht
|
||||||
|
oder für ein Ingenieurbüro, das zusätzlich Aufmaße prüfen soll.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-db-line">
|
||||||
|
<th className="th">Rolle</th>
|
||||||
|
<th className="th w-44">Seite</th>
|
||||||
|
<th className="th w-40">Vorlage</th>
|
||||||
|
<th className="th w-24 text-right">Rechte</th>
|
||||||
|
<th className="th w-24 text-right">vergeben</th>
|
||||||
|
<th className="th w-52 text-right">Aktion</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{eigene.map((r) => (
|
||||||
|
<Fragment key={r.id}>
|
||||||
|
<tr className={`row ${!r.aktiv ? "opacity-50" : ""}`}>
|
||||||
|
<td className="td">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-100">{r.name}</span>
|
||||||
|
{!r.aktiv && (
|
||||||
|
<span className="chip border-slate-700 bg-slate-800 text-slate-500">stillgelegt</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{r.beschreibung && (
|
||||||
|
<div className="mt-0.5 max-w-2xl text-[11px] leading-relaxed text-db-muted">{r.beschreibung}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="td">
|
||||||
|
<SeitenChip seite={r.seite} />
|
||||||
|
</td>
|
||||||
|
<td className="td text-xs text-db-muted">{r.vorlageLabel ?? "von Grund auf"}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{r.rechte.length}</td>
|
||||||
|
<td className="td text-right tabular-nums text-slate-300">{r.vergeben}×</td>
|
||||||
|
<td className="td">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() => { setModus({ art: "bearbeiten", rolle: r }); setFehler(null); }}
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
>
|
||||||
|
<Pencil size={12} /> bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => lauf(() => rolleStatus(r.id, !r.aktiv))}
|
||||||
|
className="btn-ghost btn-sm"
|
||||||
|
title={r.aktiv ? "stilllegen" : "wieder freigeben"}
|
||||||
|
>
|
||||||
|
<Power size={12} className={r.aktiv ? "" : "text-emerald-400"} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
disabled={pending || r.vergeben > 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Rolle „${r.name}“ endgültig löschen?`)) lauf(() => rolleLoeschen(r.id));
|
||||||
|
}}
|
||||||
|
className="btn-ghost btn-sm disabled:opacity-30"
|
||||||
|
title={r.vergeben > 0 ? "noch vergeben – nicht löschbar" : "löschen"}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{modus?.art === "bearbeiten" && modus.rolle.id === r.id && (
|
||||||
|
<tr className="border-t border-db-line bg-db-dark/40">
|
||||||
|
<td className="td" colSpan={6}>
|
||||||
|
<RolleFormular
|
||||||
|
rolle={r}
|
||||||
|
rechte={rechte}
|
||||||
|
gruppen={gruppen}
|
||||||
|
vorlagen={vorlagen}
|
||||||
|
pending={pending}
|
||||||
|
onAbbrechen={() => setModus(null)}
|
||||||
|
onSpeichern={(fd) => lauf(() => rolleSpeichern(fd))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeitenChip({ seite }: { seite: string }) {
|
||||||
|
const cls =
|
||||||
|
seite === "AG"
|
||||||
|
? "border-db-cyan/50 bg-db-cyan/10 text-db-cyan"
|
||||||
|
: seite === "AN"
|
||||||
|
? "border-amber-700 bg-amber-900/25 text-amber-300"
|
||||||
|
: "border-db-line bg-db-dark text-slate-400";
|
||||||
|
const text = seite === "AG" ? "Auftraggeberseite" : seite === "AN" ? "Auftragnehmerseite" : "Zuarbeit";
|
||||||
|
return <span className={`chip ${cls}`}>{text}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolleFormular({
|
||||||
|
rolle,
|
||||||
|
rechte,
|
||||||
|
gruppen,
|
||||||
|
vorlagen,
|
||||||
|
pending,
|
||||||
|
onAbbrechen,
|
||||||
|
onSpeichern,
|
||||||
|
}: {
|
||||||
|
rolle?: EigeneRolleAnzeige;
|
||||||
|
rechte: RechtInfo[];
|
||||||
|
gruppen: string[];
|
||||||
|
vorlagen: Vorlage[];
|
||||||
|
pending: boolean;
|
||||||
|
onAbbrechen: () => void;
|
||||||
|
onSpeichern: (fd: FormData) => void;
|
||||||
|
}) {
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<string[]>(rolle?.rechte ?? []);
|
||||||
|
const [vorlage, setVorlage] = useState(rolle?.vorlage ?? "");
|
||||||
|
const [seite, setSeite] = useState(rolle?.seite ?? "NEUTRAL");
|
||||||
|
const [vorlageUebernommen, setVorlageUebernommen] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const frei = useMemo(() => rechte.filter((r) => !r.gesperrt), [rechte]);
|
||||||
|
|
||||||
|
// Vorlage übernehmen: Rechte und Seite der Standardrolle setzen
|
||||||
|
useEffect(() => {
|
||||||
|
if (!vorlage || rolle) return;
|
||||||
|
const v = vorlagen.find((x) => x.wert === vorlage);
|
||||||
|
if (!v) return;
|
||||||
|
setGewaehlt(v.rechte.filter((r) => frei.some((f) => f.schluessel === r)));
|
||||||
|
setSeite(v.seite);
|
||||||
|
setVorlageUebernommen(v.label);
|
||||||
|
}, [vorlage, vorlagen, frei, rolle]);
|
||||||
|
|
||||||
|
const toggle = (k: string) =>
|
||||||
|
setGewaehlt((g) => (g.includes(k) ? g.filter((x) => x !== k) : [...g, k]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={onSpeichern} className="space-y-4 p-1">
|
||||||
|
{rolle && <input type="hidden" name="rolleId" value={rolle.id} />}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Bezeichnung</label>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
defaultValue={rolle?.name ?? ""}
|
||||||
|
placeholder="z. B. Bauüberwachung ohne Bescheinigung"
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!rolle && (
|
||||||
|
<div>
|
||||||
|
<label className="label mb-1.5 block">Vorlage</label>
|
||||||
|
<select
|
||||||
|
name="vorlage"
|
||||||
|
value={vorlage}
|
||||||
|
onChange={(e) => setVorlage(e.target.value)}
|
||||||
|
className="input"
|
||||||
|
>
|
||||||
|
<option value="">ohne Vorlage – von Grund auf</option>
|
||||||
|
{vorlagen.map((v) => (
|
||||||
|
<option key={v.wert} value={v.wert}>
|
||||||
|
{v.label} ({v.rechte.length} Rechte)
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[11px] text-db-muted">
|
||||||
|
Übernimmt die Rechte der Standardrolle als Ausgangspunkt.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={rolle ? "sm:col-span-2" : ""}>
|
||||||
|
<label className="label mb-1.5 block">Seite des Bauvertrags</label>
|
||||||
|
<select name="seite" value={seite} onChange={(e) => setSeite(e.target.value)} className="input">
|
||||||
|
{SEITEN.map(([k, l]) => (
|
||||||
|
<option key={k} value={k}>
|
||||||
|
{l}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-[11px] leading-relaxed text-db-muted">
|
||||||
|
{SEITEN.find(([k]) => k === seite)?.[2]}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<label className="label mb-1.5 block">Beschreibung</label>
|
||||||
|
<input
|
||||||
|
name="beschreibung"
|
||||||
|
defaultValue={rolle?.beschreibung ?? ""}
|
||||||
|
placeholder="Wofür ist diese Rolle gedacht? Erscheint bei der Zuordnung im Projekt."
|
||||||
|
className="input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vorlageUebernommen && (
|
||||||
|
<p className="rounded-md border border-db-cyan/40 bg-db-cyan/10 px-3 py-2 text-xs text-db-cyan">
|
||||||
|
<Copy size={12} className="mr-1.5 inline" />
|
||||||
|
Rechte und Seite von „{vorlageUebernommen}“ übernommen – jetzt anpassen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{seite === "AN" && (
|
||||||
|
<p className="rounded-md border border-amber-800/50 bg-amber-950/15 px-3 py-2 text-xs leading-relaxed text-amber-200/85">
|
||||||
|
<Shield size={12} className="mr-1.5 inline" />
|
||||||
|
Auf der Auftragnehmerseite gilt unabhängig von den hier gesetzten Rechten: Es werden
|
||||||
|
ausschließlich Daten der eigenen Firma angezeigt, und die Sichtbarkeit der Bereiche
|
||||||
|
hängt zusätzlich an den Schaltern des jeweiligen Projekts.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="label">Rechte ({gewaehlt.length} gewählt)</span>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<button type="button" onClick={() => setGewaehlt(frei.map((r) => r.schluessel))} className="btn-ghost btn-sm">
|
||||||
|
alle
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setGewaehlt([])} className="btn-ghost btn-sm">
|
||||||
|
keine
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{gruppen.map((g) => {
|
||||||
|
const inGruppe = rechte.filter((r) => r.gruppe === g);
|
||||||
|
if (inGruppe.length === 0) return null;
|
||||||
|
const freiInGruppe = inGruppe.filter((r) => !r.gesperrt);
|
||||||
|
const alleAn = freiInGruppe.length > 0 && freiInGruppe.every((r) => gewaehlt.includes(r.schluessel));
|
||||||
|
return (
|
||||||
|
<div key={g} className="rounded-md border border-db-line">
|
||||||
|
<div className="flex items-center justify-between border-b border-db-line bg-db-dark/40 px-3 py-1.5">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-db-muted">{g}</span>
|
||||||
|
{freiInGruppe.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setGewaehlt((cur) =>
|
||||||
|
alleAn
|
||||||
|
? cur.filter((x) => !freiInGruppe.some((r) => r.schluessel === x))
|
||||||
|
: [...new Set([...cur, ...freiInGruppe.map((r) => r.schluessel)])],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-[10px] text-db-muted hover:text-db-cyan"
|
||||||
|
>
|
||||||
|
{alleAn ? "abwählen" : "alle"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-0.5 p-2">
|
||||||
|
{inGruppe.map((r) =>
|
||||||
|
r.gesperrt ? (
|
||||||
|
<div
|
||||||
|
key={r.schluessel}
|
||||||
|
className="flex items-start gap-2 px-1 py-1 text-sm text-slate-600"
|
||||||
|
title="Wird ausschließlich am Zugang selbst vergeben"
|
||||||
|
>
|
||||||
|
<Lock size={13} className="mt-0.5 shrink-0" />
|
||||||
|
<span className="line-through">{r.label}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<label
|
||||||
|
key={r.schluessel}
|
||||||
|
className="flex cursor-pointer items-start gap-2 rounded px-1 py-1 text-sm text-slate-300 hover:bg-db-line/40"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="rechte"
|
||||||
|
value={r.schluessel}
|
||||||
|
checked={gewaehlt.includes(r.schluessel)}
|
||||||
|
onChange={() => toggle(r.schluessel)}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
<span>{r.label}</span>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" disabled={pending || gewaehlt.length === 0} className="btn-primary">
|
||||||
|
<Check size={14} /> {rolle ? "Änderungen speichern" : "Rolle anlegen"}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onAbbrechen} className="btn-ghost">
|
||||||
|
<X size={14} /> Abbrechen
|
||||||
|
</button>
|
||||||
|
{gewaehlt.length === 0 && (
|
||||||
|
<span className="self-center text-xs text-db-muted">Mindestens ein Recht auswählen.</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { requireUser } from "@/lib/auth";
|
||||||
|
import { bereinigeRechte, NICHT_FREI_VERGEBBAR, PERMISSION_INFO } from "@/lib/rbac";
|
||||||
|
import { protokolliere } from "@/lib/notify";
|
||||||
|
import type { Role, RolleSeite } from "@prisma/client";
|
||||||
|
|
||||||
|
async function nurAdmin() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user.isAdmin) throw new Error("Diese Funktion ist der Administration vorbehalten.");
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function schluesselAus(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae").replace(/ö/g, "oe").replace(/ü/g, "ue").replace(/ß/g, "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40) || "rolle";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rolleSpeichern(formData: FormData) {
|
||||||
|
const admin = await nurAdmin();
|
||||||
|
const id = String(formData.get("rolleId") ?? "");
|
||||||
|
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
if (name.length < 3) throw new Error("Bitte einen aussagekräftigen Rollennamen angeben.");
|
||||||
|
|
||||||
|
const gewaehlt = formData.getAll("rechte").map(String);
|
||||||
|
const abgewiesen = gewaehlt.filter((r) => NICHT_FREI_VERGEBBAR.includes(r as never));
|
||||||
|
if (abgewiesen.length > 0) {
|
||||||
|
const namen = abgewiesen.map((r) => PERMISSION_INFO[r as keyof typeof PERMISSION_INFO]?.label ?? r);
|
||||||
|
throw new Error(
|
||||||
|
`Diese Rechte lassen sich nicht über eine Rolle vergeben: ${namen.join(", ")}. ` +
|
||||||
|
"Die Administration wird ausschließlich am Zugang selbst gesetzt.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rechte = bereinigeRechte(gewaehlt);
|
||||||
|
if (rechte.length === 0) throw new Error("Eine Rolle ohne jedes Recht ergibt keinen Sinn.");
|
||||||
|
|
||||||
|
const seite = (formData.get("seite") as RolleSeite) || "NEUTRAL";
|
||||||
|
const vorlageRoh = String(formData.get("vorlage") ?? "");
|
||||||
|
const vorlage = vorlageRoh ? (vorlageRoh as Role) : null;
|
||||||
|
const beschreibung = String(formData.get("beschreibung") ?? "").trim() || null;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
const vorher = await prisma.customRole.findUniqueOrThrow({ where: { id } });
|
||||||
|
const entzogen = vorher.rechte.filter((r) => !rechte.includes(r as never));
|
||||||
|
await prisma.customRole.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name, beschreibung, seite, rechte },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: admin.id, aktion: "ROLLE_GEAENDERT", entitaet: "CustomRole", entitaetId: id,
|
||||||
|
details: { name, seite, anzahlRechte: rechte.length, entzogen },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Schlüssel eindeutig machen, ohne dem Anwender eine Fehlermeldung zuzumuten
|
||||||
|
let schluessel = schluesselAus(name);
|
||||||
|
for (let i = 2; await prisma.customRole.findUnique({ where: { schluessel } }); i++) {
|
||||||
|
schluessel = `${schluesselAus(name)}-${i}`;
|
||||||
|
}
|
||||||
|
const neu = await prisma.customRole.create({
|
||||||
|
data: { schluessel, name, beschreibung, seite, vorlage, rechte, createdById: admin.id },
|
||||||
|
});
|
||||||
|
await protokolliere({
|
||||||
|
userId: admin.id, aktion: "ROLLE_ANGELEGT", entitaet: "CustomRole", entitaetId: neu.id,
|
||||||
|
details: { name, seite, vorlage, anzahlRechte: rechte.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/verwaltung/rollen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rolleStatus(rolleId: string, aktiv: boolean) {
|
||||||
|
const admin = await nurAdmin();
|
||||||
|
const rolle = await prisma.customRole.findUniqueOrThrow({
|
||||||
|
where: { id: rolleId },
|
||||||
|
include: { _count: { select: { mitglieder: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!aktiv && rolle._count.mitglieder > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`„${rolle.name}“ ist derzeit ${rolle._count.mitglieder}× in Projekten vergeben. ` +
|
||||||
|
"Bitte zuerst die Zuordnungen auflösen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.customRole.update({ where: { id: rolleId }, data: { aktiv } });
|
||||||
|
await protokolliere({
|
||||||
|
userId: admin.id, aktion: aktiv ? "ROLLE_AKTIV" : "ROLLE_STILLGELEGT",
|
||||||
|
entitaet: "CustomRole", entitaetId: rolleId, details: { name: rolle.name },
|
||||||
|
});
|
||||||
|
revalidatePath("/verwaltung/rollen");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rolleLoeschen(rolleId: string) {
|
||||||
|
const admin = await nurAdmin();
|
||||||
|
const rolle = await prisma.customRole.findUniqueOrThrow({
|
||||||
|
where: { id: rolleId },
|
||||||
|
include: { _count: { select: { mitglieder: true } } },
|
||||||
|
});
|
||||||
|
if (rolle._count.mitglieder > 0)
|
||||||
|
throw new Error(
|
||||||
|
`„${rolle.name}“ ist noch ${rolle._count.mitglieder}× vergeben und kann nicht gelöscht werden.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.customRole.delete({ where: { id: rolleId } });
|
||||||
|
await protokolliere({
|
||||||
|
userId: admin.id, aktion: "ROLLE_GELOESCHT", entitaet: "CustomRole",
|
||||||
|
entitaetId: rolleId, details: { name: rolle.name, rechte: rolle.rechte },
|
||||||
|
});
|
||||||
|
revalidatePath("/verwaltung/rollen");
|
||||||
|
}
|
||||||
@@ -3,8 +3,10 @@ import { requireUser } from "@/lib/auth";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import {
|
import {
|
||||||
PERMISSIONS, PERMISSION_INFO, PERMISSION_GRUPPEN, ROLE_LABEL, ROLE_HINT,
|
PERMISSIONS, PERMISSION_INFO, PERMISSION_GRUPPEN, ROLE_LABEL, ROLE_HINT,
|
||||||
ROLE_KURZ, ROLE_SEITE, OPTION_INFO, can, type Permission,
|
ROLE_KURZ, ROLE_SEITE, OPTION_INFO, can, rechteDerRolle, NICHT_FREI_VERGEBBAR,
|
||||||
|
type Permission,
|
||||||
} from "@/lib/rbac";
|
} from "@/lib/rbac";
|
||||||
|
import { Rolleneditor } from "./Rolleneditor";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
@@ -22,7 +24,13 @@ export default async function Rollenlegende() {
|
|||||||
const rollen = Object.keys(ROLE_LABEL) as Role[];
|
const rollen = Object.keys(ROLE_LABEL) as Role[];
|
||||||
const rechte = Object.keys(PERMISSIONS) as Permission[];
|
const rechte = Object.keys(PERMISSIONS) as Permission[];
|
||||||
|
|
||||||
const belegung = await prisma.projectMember.groupBy({ by: ["role"], _count: true });
|
const [belegung, eigeneRollen] = await Promise.all([
|
||||||
|
prisma.projectMember.groupBy({ by: ["role"], _count: true }),
|
||||||
|
prisma.customRole.findMany({
|
||||||
|
include: { _count: { select: { mitglieder: true } } },
|
||||||
|
orderBy: [{ aktiv: "desc" }, { name: "asc" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
const anzahl = new Map(belegung.map((b) => [b.role, b._count]));
|
const anzahl = new Map(belegung.map((b) => [b.role, b._count]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,7 +51,37 @@ export default async function Rollenlegende() {
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Rollen ───────────────────────────────────────────── */}
|
{/* ── Rolleneditor ─────────────────────────────────────── */}
|
||||||
|
<Rolleneditor
|
||||||
|
eigene={eigeneRollen.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
beschreibung: r.beschreibung,
|
||||||
|
seite: r.seite,
|
||||||
|
vorlage: r.vorlage,
|
||||||
|
vorlageLabel: r.vorlage ? ROLE_LABEL[r.vorlage] : null,
|
||||||
|
rechte: r.rechte,
|
||||||
|
aktiv: r.aktiv,
|
||||||
|
vergeben: r._count.mitglieder,
|
||||||
|
}))}
|
||||||
|
rechte={rechte.map((p) => ({
|
||||||
|
schluessel: p,
|
||||||
|
label: PERMISSION_INFO[p].label,
|
||||||
|
gruppe: PERMISSION_INFO[p].gruppe,
|
||||||
|
gesperrt: NICHT_FREI_VERGEBBAR.includes(p),
|
||||||
|
}))}
|
||||||
|
gruppen={[...PERMISSION_GRUPPEN]}
|
||||||
|
vorlagen={rollen
|
||||||
|
.filter((r) => r !== "ADMIN")
|
||||||
|
.map((r) => ({
|
||||||
|
wert: r,
|
||||||
|
label: ROLE_LABEL[r],
|
||||||
|
seite: ROLE_SEITE[r],
|
||||||
|
rechte: rechteDerRolle(r).filter((p) => !NICHT_FREI_VERGEBBAR.includes(p)),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── Standardrollen ───────────────────────────────────── */}
|
||||||
{(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
|
{(["AG", "AN", "NEUTRAL"] as const).map((seite) => {
|
||||||
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
|
const gruppe = rollen.filter((r) => ROLE_SEITE[r] === seite);
|
||||||
if (gruppe.length === 0) return null;
|
if (gruppe.length === 0) return null;
|
||||||
@@ -51,6 +89,9 @@ export default async function Rollenlegende() {
|
|||||||
<section key={seite} className="card overflow-hidden">
|
<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">
|
<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={`chip ${SEITE_TEXT[seite].farbe}`}>{SEITE_TEXT[seite].titel}</span>
|
||||||
|
<span className="chip border-db-line bg-db-dark text-[10px] text-db-muted">
|
||||||
|
Standardrollen · unveränderlich
|
||||||
|
</span>
|
||||||
<span className="text-xs text-db-muted">
|
<span className="text-xs text-db-muted">
|
||||||
{seite === "AG" && "vergibt, überwacht, prüft, gibt frei – sieht das gesamte Projekt"}
|
{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 === "AN" && "liefert, meldet, rechnet ab – sieht ausschließlich die eigene Firma"}
|
||||||
@@ -100,6 +141,11 @@ export default async function Rollenlegende() {
|
|||||||
{ROLE_KURZ[r]}
|
{ROLE_KURZ[r]}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
{eigeneRollen.filter((e) => e.aktiv).map((e) => (
|
||||||
|
<th key={e.id} className="th text-center text-db-cyan" title={`${e.name} (eigene Rolle)`}>
|
||||||
|
{e.name.slice(0, 6)}…
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -109,7 +155,7 @@ export default async function Rollenlegende() {
|
|||||||
return (
|
return (
|
||||||
<Fragmentartig key={gruppe}>
|
<Fragmentartig key={gruppe}>
|
||||||
<tr className="border-t border-db-line/70 bg-db-dark/40">
|
<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}>
|
<td className="td sticky left-0 bg-db-dark/40 text-[11px] font-semibold uppercase tracking-wider text-db-muted" colSpan={rollen.length + eigeneRollen.filter((e) => e.aktiv).length + 1}>
|
||||||
{gruppe}
|
{gruppe}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -140,6 +186,21 @@ export default async function Rollenlegende() {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{eigeneRollen.filter((e) => e.aktiv).map((e) => {
|
||||||
|
const erlaubt = e.rechte.includes(p);
|
||||||
|
const optional = !erlaubt && e.seite !== "AG" && !!info.optional;
|
||||||
|
return (
|
||||||
|
<td key={e.id} 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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -206,6 +267,8 @@ export default async function Rollenlegende() {
|
|||||||
<li>· Auftragnehmer sehen ausschließlich Daten der eigenen Firma.</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>· 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>
|
<li>· Es muss stets mindestens ein aktiver Administrationszugang bestehen.</li>
|
||||||
|
<li>· Die elf Standardrollen sind Teil des Programms und weder änderbar noch löschbar.</li>
|
||||||
|
<li>· Administrationsrechte lassen sich nicht über eine eigene Rolle vergeben.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getSessionUser, rolesInProject } from "@/lib/auth";
|
import { getSessionUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { isExternalOnly } from "@/lib/rbac";
|
|
||||||
import { leseDatei } from "@/lib/storage";
|
import { leseDatei } from "@/lib/storage";
|
||||||
import { protokolliere } from "@/lib/notify";
|
import { protokolliere } from "@/lib/notify";
|
||||||
|
|
||||||
@@ -16,9 +15,9 @@ export async function GET(_req: Request, { params }: { params: Promise<{ version
|
|||||||
});
|
});
|
||||||
if (!version) return new NextResponse("Nicht gefunden.", { status: 404 });
|
if (!version) return new NextResponse("Nicht gefunden.", { status: 404 });
|
||||||
|
|
||||||
const roles = await rolesInProject(user.id, version.document.projectId);
|
const rechte = await rechteImProjekt(user.id, version.document.projectId);
|
||||||
if (roles.length === 0) return new NextResponse("Kein Zugriff.", { status: 403 });
|
if (rechte.leer) return new NextResponse("Kein Zugriff.", { status: 403 });
|
||||||
if (version.document.vertraulich !== "OFFEN" && isExternalOnly(roles))
|
if (version.document.vertraulich !== "OFFEN" && rechte.nurEigeneFirma)
|
||||||
return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 });
|
return new NextResponse("Kein Zugriff auf diese Unterlage.", { status: 403 });
|
||||||
|
|
||||||
let daten: Buffer;
|
let daten: Buffer;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { getSessionUser, rolesInProject } from "@/lib/auth";
|
import { getSessionUser, rechteImProjekt } from "@/lib/auth";
|
||||||
import { UEBERGABE_LABEL } from "@/lib/fristen";
|
import { UEBERGABE_LABEL } from "@/lib/fristen";
|
||||||
|
|
||||||
/** Export des Soll-Ist-Standes – bewusst im Format der Ursprungs-Excel. */
|
/** Export des Soll-Ist-Standes – bewusst im Format der Ursprungs-Excel. */
|
||||||
@@ -8,7 +8,7 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
|
|||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const user = await getSessionUser();
|
const user = await getSessionUser();
|
||||||
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 });
|
if (!user) return new NextResponse("Nicht angemeldet.", { status: 401 });
|
||||||
if ((await rolesInProject(user.id, id)).length === 0)
|
if ((await rechteImProjekt(user.id, id)).leer)
|
||||||
return new NextResponse("Kein Zugriff.", { status: 403 });
|
return new NextResponse("Kein Zugriff.", { status: 403 });
|
||||||
|
|
||||||
const akte = new URL(req.url).searchParams.get("akte");
|
const akte = new URL(req.url).searchParams.get("akte");
|
||||||
|
|||||||
+59
-10
@@ -5,6 +5,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { randomBytes } from "crypto";
|
import { randomBytes } from "crypto";
|
||||||
import { prisma } from "./db";
|
import { prisma } from "./db";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
import { rechtelage, rechteDerRolle, OPTIONEN_OFFEN, type Rechtelage, type EigeneRolle } from "./rbac";
|
||||||
|
|
||||||
const COOKIE = "baudoc_session";
|
const COOKIE = "baudoc_session";
|
||||||
const SESSION_DAYS = 12;
|
const SESSION_DAYS = 12;
|
||||||
@@ -109,16 +110,47 @@ export async function rolesInProject(userId: string, projectId: string): Promise
|
|||||||
where: { userId, projectId },
|
where: { userId, projectId },
|
||||||
select: { role: true },
|
select: { role: true },
|
||||||
});
|
});
|
||||||
const roles = memberships.map((m) => m.role);
|
const roles = memberships.map((m) => m.role).filter((r): r is Role => !!r);
|
||||||
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
|
if (user?.isAdmin && !roles.includes("ADMIN")) roles.push("ADMIN");
|
||||||
return roles;
|
return roles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die vollständige Rechtelage einer Person in einem Projekt: Standardrollen
|
||||||
|
* und selbst angelegte Rollen zusammengeführt, Projektschalter berücksichtigt.
|
||||||
|
*
|
||||||
|
* Dies ist die einzige Stelle, an der Berechtigungen ermittelt werden. Wer
|
||||||
|
* eine Prüfung braucht, holt sich diese Lage – nicht die rohen Rollen.
|
||||||
|
*/
|
||||||
|
export async function rechteImProjekt(userId: string, projectId: string): Promise<Rechtelage> {
|
||||||
|
const [user, mitgliedschaften, projekt] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { isAdmin: true } }),
|
||||||
|
prisma.projectMember.findMany({
|
||||||
|
where: { userId, projectId },
|
||||||
|
include: { customRole: true },
|
||||||
|
}),
|
||||||
|
prisma.project.findUnique({ where: { id: projectId } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rollen = mitgliedschaften.map((m) => m.role).filter((r): r is Role => !!r);
|
||||||
|
if (user?.isAdmin && !rollen.includes("ADMIN")) rollen.push("ADMIN");
|
||||||
|
|
||||||
|
const eigene: EigeneRolle[] = mitgliedschaften
|
||||||
|
.map((m) => m.customRole)
|
||||||
|
.filter((c): c is NonNullable<typeof c> => !!c && c.aktiv)
|
||||||
|
.map((c) => ({ id: c.id, name: c.name, seite: c.seite as EigeneRolle["seite"], rechte: c.rechte }));
|
||||||
|
|
||||||
|
const { optionenAus } = await import("./projekte");
|
||||||
|
const optionen = projekt ? optionenAus(projekt) : OPTIONEN_OFFEN;
|
||||||
|
|
||||||
|
return rechtelage(rollen, eigene, optionen);
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireProjectAccess(projectId: string) {
|
export async function requireProjectAccess(projectId: string) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
if (rechte.leer) redirect("/projekte?fehler=kein-zugriff");
|
||||||
return { user, roles };
|
return { user, rechte };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -128,21 +160,38 @@ export async function requireProjectAccess(projectId: string) {
|
|||||||
*/
|
*/
|
||||||
export async function projektKontext(projectId: string) {
|
export async function projektKontext(projectId: string) {
|
||||||
const user = await requireUser();
|
const user = await requireUser();
|
||||||
const roles = await rolesInProject(user.id, projectId);
|
|
||||||
if (roles.length === 0) redirect("/projekte?fehler=kein-zugriff");
|
|
||||||
|
|
||||||
const projekt = await prisma.project.findUnique({ where: { id: projectId } });
|
const projekt = await prisma.project.findUnique({ where: { id: projectId } });
|
||||||
if (!projekt) redirect("/projekte?fehler=nicht-gefunden");
|
if (!projekt) redirect("/projekte?fehler=nicht-gefunden");
|
||||||
|
|
||||||
|
const rechte = await rechteImProjekt(user.id, projectId);
|
||||||
|
if (rechte.leer) redirect("/projekte?fehler=kein-zugriff");
|
||||||
|
|
||||||
const { optionenAus } = await import("./projekte");
|
const { optionenAus } = await import("./projekte");
|
||||||
const { isExternalOnly } = await import("./rbac");
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
roles,
|
rechte,
|
||||||
projekt,
|
projekt,
|
||||||
optionen: optionenAus(projekt),
|
optionen: optionenAus(projekt),
|
||||||
/** true = darf ausschließlich Daten der eigenen Firma sehen */
|
/** true = darf ausschließlich Daten der eigenen Firma sehen */
|
||||||
nurEigeneFirma: isExternalOnly(roles),
|
nurEigeneFirma: rechte.nurEigeneFirma,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Das Recht, Projekte anzulegen, ist nicht projektbezogen: Wer irgendwo
|
||||||
|
* Projektleitung ist – über eine Standardrolle oder eine eigene Rolle mit
|
||||||
|
* diesem Recht – darf ein neues Projekt anlegen.
|
||||||
|
*/
|
||||||
|
export async function darfProjekteAnlegen(userId: string, isAdmin: boolean): Promise<boolean> {
|
||||||
|
if (isAdmin) return true;
|
||||||
|
const mitgliedschaften = await prisma.projectMember.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: { customRole: { select: { rechte: true, aktiv: true } } },
|
||||||
|
});
|
||||||
|
return mitgliedschaften.some(
|
||||||
|
(m) =>
|
||||||
|
(m.role && rechteDerRolle(m.role).includes("projekt.anlegen")) ||
|
||||||
|
(m.customRole?.aktiv && m.customRole.rechte.includes("projekt.anlegen")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+111
-37
@@ -142,6 +142,15 @@ export const PERMISSION_GRUPPEN = [
|
|||||||
"Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration",
|
"Verträge", "Stunden und Aufmaß", "Rechnungsprüfung", "Kommunikation", "Administration",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/** Anzeigename einer Zuordnung – Standardrolle oder selbst angelegte Rolle. */
|
||||||
|
export function rollenBezeichnung(m: {
|
||||||
|
role: Role | null;
|
||||||
|
customRole?: { name: string } | null;
|
||||||
|
}): string {
|
||||||
|
if (m.role) return ROLE_LABEL[m.role];
|
||||||
|
return m.customRole?.name ?? "ohne Rolle";
|
||||||
|
}
|
||||||
|
|
||||||
export const ROLE_KURZ: Record<Role, string> = {
|
export const ROLE_KURZ: Record<Role, string> = {
|
||||||
ADMIN: "ADM",
|
ADMIN: "ADM",
|
||||||
PROJEKTLEITER: "PL",
|
PROJEKTLEITER: "PL",
|
||||||
@@ -199,22 +208,44 @@ export const ROLE_HINT: Record<Role, string> = {
|
|||||||
LESER: "Nur lesen.",
|
LESER: "Nur lesen.",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
* Auftragnehmer und andere Externe sehen ausschließlich Daten der eigenen Firma.
|
// Rechte einer Rolle
|
||||||
*
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
* Wichtig: Diese Funktion steuert die DATENAUSWAHL und darf niemals durch eine
|
|
||||||
* Projekteinstellung aufgeweicht werden. Die Schalter unter `ProjektOptionen`
|
/** Alle Rechte, die eine Standardrolle mitbringt. */
|
||||||
* entscheiden nur, ob ein Bereich überhaupt sichtbar ist – nicht, wessen Daten
|
export function rechteDerRolle(role: Role): Permission[] {
|
||||||
* darin erscheinen.
|
return (Object.keys(PERMISSIONS) as Permission[]).filter((p) =>
|
||||||
*/
|
(PERMISSIONS[p] as readonly Role[]).includes(role),
|
||||||
export function isExternalOnly(roles: Role[]): boolean {
|
);
|
||||||
const privileged: Role[] = [
|
|
||||||
"ADMIN", "PROJEKTLEITER", "BAUHERRENVERTRETER", "BAUUEBERWACHER",
|
|
||||||
"FACHBAUUEBERWACHER", "KAUFMANN",
|
|
||||||
];
|
|
||||||
return roles.length > 0 && !roles.some((r) => privileged.includes(r));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rechte, die eine selbst angelegte Rolle niemals enthalten darf.
|
||||||
|
* Die Administration wird ausschließlich über das Kennzeichen am Zugang
|
||||||
|
* vergeben – sonst ließe sich über eine eigene Rolle die Benutzerverwaltung
|
||||||
|
* erreichen und damit jede andere Schranke aushebeln.
|
||||||
|
*/
|
||||||
|
export const NICHT_FREI_VERGEBBAR: Permission[] = ["admin.benutzer", "admin.firmen", "admin.audit"];
|
||||||
|
|
||||||
|
export function istFreiVergebbar(p: string): p is Permission {
|
||||||
|
return p in PERMISSION_INFO && !NICHT_FREI_VERGEBBAR.includes(p as Permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bereinigt eine Rechteauswahl: nur bekannte und frei vergebbare Schlüssel. */
|
||||||
|
export function bereinigeRechte(rechte: string[]): Permission[] {
|
||||||
|
return [...new Set(rechte.filter(istFreiVergebbar))] as Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Seite = "AG" | "AN" | "NEUTRAL";
|
||||||
|
|
||||||
|
/** Eine selbst angelegte Rolle, so wie die Rechteprüfung sie braucht. */
|
||||||
|
export type EigeneRolle = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
seite: Seite;
|
||||||
|
rechte: string[];
|
||||||
|
};
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
// Projektbezogene Einstellungen
|
// Projektbezogene Einstellungen
|
||||||
// ═══════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
@@ -288,34 +319,77 @@ const ENTZIEHBAR_FUER_EXTERNE: Partial<Record<Permission, keyof ProjektOptionen>
|
|||||||
"dokument.hochladen": "anDarfDokumente",
|
"dokument.hochladen": "anDarfDokumente",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
// Rechtelage einer Person in einem Projekt
|
||||||
|
// ═══════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Berechtigung unter Berücksichtigung der Projekteinstellungen.
|
* Bündelt, was jemand in einem Projekt darf – aus Standardrollen und eigenen
|
||||||
* Für Rollen der Bauherrenseite identisch mit `can()`.
|
* Rollen zusammengeführt. Einzige Stelle, an der Rechte ausgewertet werden.
|
||||||
*/
|
*/
|
||||||
export function canInProject(
|
export type Rechtelage = {
|
||||||
roles: Role[],
|
/** zugewiesene Standardrollen */
|
||||||
permission: Permission,
|
rollen: Role[];
|
||||||
|
/** zugewiesene selbst angelegte Rollen */
|
||||||
|
eigene: EigeneRolle[];
|
||||||
|
/** keinerlei Zuordnung – kein Zugriff auf das Projekt */
|
||||||
|
leer: boolean;
|
||||||
|
/** true = sieht ausschließlich Daten der eigenen Firma */
|
||||||
|
nurEigeneFirma: boolean;
|
||||||
|
/** Klartext aller Rollen, für Anzeige */
|
||||||
|
bezeichnungen: string[];
|
||||||
|
/** Rechteprüfung inklusive der Projektschalter */
|
||||||
|
darf: (permission: Permission) => boolean;
|
||||||
|
/** wie darf(), wirft aber bei fehlender Berechtigung */
|
||||||
|
pruefe: (permission: Permission) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function rechtelage(
|
||||||
|
rollen: Role[],
|
||||||
|
eigene: EigeneRolle[],
|
||||||
optionen: ProjektOptionen,
|
optionen: ProjektOptionen,
|
||||||
): boolean {
|
): Rechtelage {
|
||||||
const extern = isExternalOnly(roles);
|
const leer = rollen.length === 0 && eigene.length === 0;
|
||||||
|
|
||||||
if (extern) {
|
// Bauherrenseite: mindestens eine Rolle steht auf der Auftraggeberseite.
|
||||||
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
const aufAGSeite =
|
||||||
if (entzug && !optionen[entzug]) return false;
|
rollen.some((r) => ROLE_SEITE[r] === "AG" || r === "ADMIN") ||
|
||||||
|
eigene.some((e) => e.seite === "AG");
|
||||||
|
const nurEigeneFirma = !leer && !aufAGSeite;
|
||||||
|
|
||||||
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
const grundmenge = new Set<string>();
|
||||||
if (gewaehrung && optionen[gewaehrung]) return true;
|
for (const r of rollen) for (const p of rechteDerRolle(r)) grundmenge.add(p);
|
||||||
}
|
for (const e of eigene) for (const p of e.rechte) grundmenge.add(p);
|
||||||
|
|
||||||
return can(roles, permission);
|
const darf = (permission: Permission): boolean => {
|
||||||
|
if (nurEigeneFirma) {
|
||||||
|
const entzug = ENTZIEHBAR_FUER_EXTERNE[permission];
|
||||||
|
if (entzug && !optionen[entzug]) return false;
|
||||||
|
|
||||||
|
const gewaehrung = OPTIONAL_FUER_EXTERNE[permission];
|
||||||
|
if (gewaehrung && optionen[gewaehrung] && grundmenge.size > 0) return true;
|
||||||
|
}
|
||||||
|
return grundmenge.has(permission);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
rollen,
|
||||||
|
eigene,
|
||||||
|
leer,
|
||||||
|
nurEigeneFirma,
|
||||||
|
bezeichnungen: [...rollen.map((r) => ROLE_LABEL[r]), ...eigene.map((e) => e.name)],
|
||||||
|
darf,
|
||||||
|
pruefe: (permission) => {
|
||||||
|
if (!darf(permission)) throw new Error(`Keine Berechtigung für „${PERMISSION_INFO[permission].label}“.`);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertCanInProject(
|
/** Voreinstellung, wenn keine Projektschalter im Spiel sind (z. B. Verwaltung). */
|
||||||
roles: Role[],
|
export const OPTIONEN_OFFEN: ProjektOptionen = {
|
||||||
permission: Permission,
|
anSichtVertrag: true,
|
||||||
optionen: ProjektOptionen,
|
anSichtRechnungen: true,
|
||||||
) {
|
anSichtBautagebuch: true,
|
||||||
if (!canInProject(roles, permission, optionen)) {
|
anSichtDokumentation: true,
|
||||||
throw new Error(`Keine Berechtigung für "${permission}".`);
|
anDarfDokumente: true,
|
||||||
}
|
};
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { prisma } from "./db";
|
||||||
|
import { ROLE_LABEL, ROLE_HINT, ROLE_SEITE } from "./rbac";
|
||||||
|
import type { Role } from "@prisma/client";
|
||||||
|
|
||||||
|
export type RollenOption = {
|
||||||
|
wert: string; // Standardrolle oder "custom:<id>"
|
||||||
|
label: string;
|
||||||
|
hinweis: string;
|
||||||
|
eigen: boolean;
|
||||||
|
seite: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Alle vergebbaren Rollen: die elf Standardrollen plus die aktiven eigenen. */
|
||||||
|
export async function rollenAuswahl(): Promise<RollenOption[]> {
|
||||||
|
const eigene = await prisma.customRole.findMany({
|
||||||
|
where: { aktiv: true },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const standard = (Object.keys(ROLE_LABEL) as Role[])
|
||||||
|
.filter((r) => r !== "ADMIN") // Administration wird am Zugang vergeben, nicht im Projekt
|
||||||
|
.map((r) => ({
|
||||||
|
wert: r,
|
||||||
|
label: ROLE_LABEL[r],
|
||||||
|
hinweis: ROLE_HINT[r],
|
||||||
|
eigen: false,
|
||||||
|
seite: ROLE_SEITE[r] as string,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [
|
||||||
|
...standard,
|
||||||
|
...eigene.map((e) => ({
|
||||||
|
wert: `custom:${e.id}`,
|
||||||
|
label: e.name,
|
||||||
|
hinweis: e.beschreibung ?? `Eigene Rolle mit ${e.rechte.length} Rechten.`,
|
||||||
|
eigen: true,
|
||||||
|
seite: e.seite as string,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zerlegt einen Auswahlwert in Standardrolle oder eigene Rolle. */
|
||||||
|
export function zerlegeRollenwert(wert: string): { role: Role | null; customRoleId: string | null } {
|
||||||
|
return wert.startsWith("custom:")
|
||||||
|
? { role: null, customRoleId: wert.slice(7) }
|
||||||
|
: { role: wert as Role, customRoleId: null };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user