Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import QRCode from "qrcode";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { newTotpSecret, totpUri } from "@/server/mfa";
|
||||
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
|
||||
import { disableOwnMfa } from "@/server/actions/account";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { TenantMfaEnrollForm } from "@/components/tenant-mfa-enroll-form";
|
||||
import { TenantRecoveryRegenForm } from "@/components/tenant-recovery-regen-form";
|
||||
import { PasskeyManager } from "@/components/passkey-manager";
|
||||
import { ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
||||
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
||||
|
||||
/**
|
||||
* Persönliches Konto des Mandanten-Nutzers (Paket C): optionale MFA selbst
|
||||
* aktivieren/deaktivieren. Login verlangt den Code erst, sobald MFA aktiv ist.
|
||||
*/
|
||||
export default async function AccountPage() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const user = await db.user.findUnique({ where: { id: session.user.id } });
|
||||
if (!user) redirect("/login");
|
||||
// Option C (WS4): Passwort/MFA gehören der GLOBALEN Identity.
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) redirect("/login");
|
||||
const identity = await prisma.identity.findUnique({ where: { id: identityId } });
|
||||
if (!identity) redirect("/login");
|
||||
// SEC2: Passwort-Policy des Mandanten fuer den Hinweistext der Selbstaenderung.
|
||||
const settings = await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } });
|
||||
const policyHint = describePasswordPolicy(resolvePasswordPolicy(settings?.securityPolicy));
|
||||
|
||||
const enrolled = !!identity.mfaEnrolledAt;
|
||||
const recoveryLeft = Array.isArray(identity.recoveryCodes) ? identity.recoveryCodes.length : 0;
|
||||
|
||||
// SEC3-b / WS4b: registrierte Passkeys der GLOBALEN Identity (identitätsgebunden).
|
||||
const passkeys = await prisma.webAuthnCredential.findMany({
|
||||
where: { identityId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, deviceName: true, createdAt: true },
|
||||
});
|
||||
const passkeyItems = passkeys.map((p) => ({ id: p.id, deviceName: p.deviceName, createdLabel: p.createdAt.toLocaleDateString("de-DE") }));
|
||||
|
||||
let qr: string | null = null;
|
||||
let secret: string | null = null;
|
||||
if (!enrolled) {
|
||||
secret = identity.mfaSecret ? decryptSecret(identity.mfaSecret) : newTotpSecret();
|
||||
if (!identity.mfaSecret) await prisma.identity.update({ where: { id: identityId }, data: { mfaSecret: encryptSecret(secret) } });
|
||||
qr = await QRCode.toDataURL(totpUri(identity.email, secret), { margin: 1, width: 192 });
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Konto" title="Mein Konto" sub={`${user.name} · ${user.email}`} />
|
||||
|
||||
<div className="mt-4 max-w-xl">
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Zwei-Faktor-Authentifizierung (optional)</p>
|
||||
<Pill tone={enrolled ? "ok" : "mut"}>{enrolled ? "Aktiv" : "Inaktiv"}</Pill>
|
||||
</div>
|
||||
|
||||
{enrolled ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">MFA ist aktiv. Verbleibende Recovery-Codes: {recoveryLeft}.</p>
|
||||
{/* Re-Authentifizierung (F-08): aktueller TOTP-Code zum Deaktivieren nötig. */}
|
||||
<form action={disableOwnMfa} className="flex items-center gap-2">
|
||||
<input
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-stelliger Code"
|
||||
className="h-8 w-36 rounded-md border bg-background px-2 text-sm"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm">MFA deaktivieren</Button>
|
||||
</form>
|
||||
<TenantRecoveryRegenForm />
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 space-y-4">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Scannen Sie den QR-Code mit einer Authenticator-App und bestätigen Sie mit dem angezeigten Code.
|
||||
</p>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qr!} alt="QR-Code für die Authenticator-App" width={192} height={192} className="rounded-lg border bg-white p-2" />
|
||||
<code className="select-all break-all font-mono text-xs text-muted-foreground">{secret}</code>
|
||||
</div>
|
||||
<TenantMfaEnrollForm />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<p className="mb-1 text-[12.5px] font-semibold">Passkeys (WebAuthn)</p>
|
||||
<p className="mb-2 text-[11.5px] text-muted-foreground">Alternativer zweiter Faktor: Anmeldung per Gerät (Fingerabdruck/PIN/Sicherheitsschlüssel). Beim Login ist wahlweise TOTP oder Passkey möglich.</p>
|
||||
<PasskeyManager credentials={passkeyItems} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEC2: Passwort selbst aendern — meldet andere Sitzungen ab, behaelt die aktuelle. */}
|
||||
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Passwort ändern</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">
|
||||
Nach der Änderung werden alle anderen Sitzungen abgemeldet; diese bleibt bestehen.
|
||||
</p>
|
||||
<ChangePasswordSelfForm domain="tenant" policyHint={policyHint} />
|
||||
</div>
|
||||
|
||||
{/* Option C: Die E-Mail ist der globale Anmeldename der Identity. Ihre
|
||||
Änderung als Identity-Operation ist bewusst Phase 2 (FEINDESIGN §13). */}
|
||||
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">E-Mail-Adresse</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Aktuell: {identity.email}. Die Adresse ist zugleich Ihr globaler Anmeldename.
|
||||
Eine Änderung ist derzeit nur über die Plattform-Administration möglich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
redirect(`/assets?edit=${id}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
redirect(`/assets?detail=${id}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("assets");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Anlegen läuft jetzt als Popup über die Listen-Seite.
|
||||
export default function Page() {
|
||||
redirect("/assets?new=1");
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus, Search } from "lucide-react";
|
||||
import type { AssetType } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import {
|
||||
CiaBadge,
|
||||
CiaLegend,
|
||||
CriticalityPill,
|
||||
KpiCard,
|
||||
OwnerChip,
|
||||
PageHead,
|
||||
Pill,
|
||||
Tag,
|
||||
} from "@/components/mockup-ui";
|
||||
import {
|
||||
AssetCreateModal,
|
||||
AssetDetailModal,
|
||||
AssetEditModal,
|
||||
} from "@/components/asset-modals";
|
||||
import {
|
||||
SupplierDetailModal,
|
||||
SupplierEditModal,
|
||||
type SupplierAssetDetail,
|
||||
} from "@/components/supplier-modals";
|
||||
import {
|
||||
ServiceDetailModal,
|
||||
ServiceEditModal,
|
||||
type ServiceAssetDetail,
|
||||
} from "@/components/service-modals";
|
||||
import {
|
||||
SoftwareDetailModal,
|
||||
SoftwareEditModal,
|
||||
type SoftwareAssetDetail,
|
||||
} from "@/components/software-modals";
|
||||
import {
|
||||
ProjectCreateModal,
|
||||
ProjectDetailModal,
|
||||
ProjectEditModal,
|
||||
type ProjectAssetDetail,
|
||||
} from "@/components/project-modals";
|
||||
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE, SOFTWARE_INCLUDE, PROJECT_INCLUDE } from "@/lib/supplier-include";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "SOFTWARE", "PROJECT", "PERSON", "DATA"] as const;
|
||||
|
||||
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
||||
|
||||
export default async function AssetsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; type?: string; view?: string; detail?: string; edit?: string; new?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tProj = await getTranslations("projects");
|
||||
const tSw = await getTranslations("software");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const { q, type } = params;
|
||||
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
/* ── Auto-Sicht „Kritische IT-Dienste" (schreibgeschützt, aus Assetinventar/BIA) ──
|
||||
Ziel des früheren Registers REG-CRIT-SERVICES: keine gepflegte Liste, sondern eine
|
||||
abgeleitete Sicht. Kritisch = hohe Verfügbarkeit (≥3) ODER stützt einen BIA-kritischen
|
||||
Prozess. RTO/RPO stammen aus den verknüpften Prozessen (BiaEntry, strengster Wert). */
|
||||
if (params.view === "critical") {
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const critical = await db.asset.findMany({
|
||||
where: {
|
||||
type: { in: ["IT_SERVICE", "SYSTEM", "APPLICATION", "SOFTWARE"] },
|
||||
OR: [
|
||||
{ availability: { gte: 3 } },
|
||||
{ processAssets: { some: { process: { bia: { criticality: { gte: 3 } } } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
processAssets: { include: { process: { select: { id: true, name: true, bia: { select: { criticality: true, rtoHours: true, rpoHours: true } } } } } },
|
||||
relationsFrom: { include: { relatedAsset: { select: { name: true } } } },
|
||||
},
|
||||
orderBy: [{ availability: "desc" }, { name: "asc" }],
|
||||
take: 300,
|
||||
});
|
||||
const min = (xs: (number | null | undefined)[]) => {
|
||||
const v = xs.filter((x): x is number => typeof x === "number");
|
||||
return v.length ? Math.min(...v) : null;
|
||||
};
|
||||
const fmtH = (h: number | null) => (h == null ? tc("none") : h >= 24 && h % 24 === 0 ? `${h / 24} d` : `${h} h`);
|
||||
const rows = critical.map((a) => {
|
||||
const bias = a.processAssets.map((pa) => pa.process.bia).filter(Boolean);
|
||||
const biaCrit = Math.max(a.availability, ...bias.map((b) => b!.criticality));
|
||||
return {
|
||||
a,
|
||||
biaCrit,
|
||||
rto: min(bias.map((b) => b!.rtoHours)),
|
||||
rpo: min(bias.map((b) => b!.rpoHours)),
|
||||
processes: a.processAssets.map((pa) => pa.process.name),
|
||||
deps: a.relationsFrom.map((r) => r.relatedAsset.name),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("criticalTitle")} sub={t("criticalSub")} actions={
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/assets" />}>{t("backToInventory")}</Button>
|
||||
} />
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("criticalService")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("biaCriticality")}</TableHead>
|
||||
<TableHead>RTO</TableHead>
|
||||
<TableHead>RPO</TableHead>
|
||||
<TableHead>{t("supportedProcesses")}</TableHead>
|
||||
<TableHead>{t("dependencies")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("criticalEmpty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map(({ a, biaCrit, rto, rpo, processes, deps }) => (
|
||||
<TableRow key={a.id}>
|
||||
<TableCell>
|
||||
<Link href={`/assets?detail=${a.id}`} className="font-bold hover:underline">{a.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell><Tag>{tType(a.type)}</Tag></TableCell>
|
||||
<TableCell><CriticalityPill level={biaCrit} label={tCrit(String(biaCrit))} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">{fmtH(rto)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{fmtH(rpo)}</TableCell>
|
||||
<TableCell className="max-w-52 truncate text-muted-foreground">{processes.join(", ") || tc("none")}</TableCell>
|
||||
<TableCell className="max-w-52 truncate text-muted-foreground">{deps.join(", ") || tc("none")}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("criticalNote")}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const [assets, allAssets] = await Promise.all([
|
||||
db.asset.findMany({
|
||||
where: {
|
||||
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
||||
...(activeType ? { type: activeType } : {}),
|
||||
},
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
supplierProfile: { select: { id: true } },
|
||||
serviceProfile: { select: { id: true } },
|
||||
relationsFrom: {
|
||||
include: { relatedAsset: { select: { name: true } } },
|
||||
take: 4,
|
||||
},
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
}),
|
||||
db.asset.findMany({
|
||||
select: { type: true, ownerId: true, confidentiality: true, integrity: true, availability: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = allAssets.length;
|
||||
const typeCount = new Set(allAssets.map((a) => a.type)).size;
|
||||
const highProtection = allAssets.filter(
|
||||
(a) => Math.max(a.confidentiality, a.integrity, a.availability) >= 3
|
||||
).length;
|
||||
const withoutOwner = allAssets.filter((a) => !a.ownerId).length;
|
||||
const suppliers = allAssets.filter((a) => a.type === "SUPPLIER").length;
|
||||
|
||||
const canWrite = hasPermission(session, "asset:write");
|
||||
|
||||
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const isEdit = Boolean(params.edit && canWrite);
|
||||
|
||||
// Art des Popups vorab bestimmen: Lieferanten & IT-Services mit Fachprofil
|
||||
// werden direkt hier als Fach-Cockpit gerendert (backHref="/assets"), sodass
|
||||
// man auf der Asset-Seite bleibt; profillose Alt-Assets bleiben normale Assets.
|
||||
const modalMeta = modalId
|
||||
? await db.asset.findUnique({
|
||||
where: { id: modalId },
|
||||
select: {
|
||||
type: true,
|
||||
supplierProfile: { select: { id: true } },
|
||||
serviceProfile: { select: { id: true } },
|
||||
softwareProfile: { select: { id: true } },
|
||||
projectProfile: { select: { id: true } },
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const modalKind: "supplier" | "service" | "software" | "project" | "asset" | null = !modalMeta
|
||||
? null
|
||||
: modalMeta.type === "SUPPLIER" && modalMeta.supplierProfile
|
||||
? "supplier"
|
||||
: modalMeta.type === "IT_SERVICE" && modalMeta.serviceProfile
|
||||
? "service"
|
||||
: modalMeta.type === "SOFTWARE" && modalMeta.softwareProfile
|
||||
? "software"
|
||||
: modalMeta.type === "PROJECT" && modalMeta.projectProfile
|
||||
? "project"
|
||||
: "asset";
|
||||
|
||||
const supplierModal =
|
||||
modalKind === "supplier"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||
: null;
|
||||
const serviceModal =
|
||||
modalKind === "service"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
|
||||
: null;
|
||||
const softwareModal =
|
||||
modalKind === "software"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: SOFTWARE_INCLUDE })) as SoftwareAssetDetail | null)
|
||||
: null;
|
||||
const projectModal =
|
||||
modalKind === "project"
|
||||
? ((await db.asset.findUnique({ where: { id: modalId! }, include: PROJECT_INCLUDE })) as ProjectAssetDetail | null)
|
||||
: null;
|
||||
const providers =
|
||||
(modalKind === "service" || modalKind === "software") && isEdit
|
||||
? await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const modalAsset =
|
||||
modalKind === "asset"
|
||||
? await db.asset.findUnique({
|
||||
where: { id: modalId! },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
relationsFrom: {
|
||||
include: {
|
||||
relatedAsset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
|
||||
processAssets: { include: { process: { select: { id: true, name: true } } } },
|
||||
riskAssets: {
|
||||
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
|
||||
orderBy: { risk: { score: "desc" } },
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const users =
|
||||
canWrite && (params.edit || params.new)
|
||||
? await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
const otherAssets =
|
||||
canWrite && params.edit && modalAsset
|
||||
? await db.asset.findMany({
|
||||
where: { id: { not: modalAsset.id } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const typeHref = (v: string | null) =>
|
||||
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("invTitle")}
|
||||
sub={t("invSub", { count: total })}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||
{t("excelImport")}
|
||||
</Button>
|
||||
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||
{t("export")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
activeType === "PROJECT" ? (
|
||||
<Button nativeButton={false} render={<Link href="/assets?type=PROJECT&new=1" />}>
|
||||
<Plus className="size-4" /> {tProj("newProject")}
|
||||
</Button>
|
||||
) : activeType === "SOFTWARE" ? (
|
||||
<Button nativeButton={false} render={<Link href="/suppliers?tab=software&new=1" />}>
|
||||
<Plus className="size-4" /> {tSw("newSoftware")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button nativeButton={false} render={<Link href="/assets?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newAsset")}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard label={t("kpiTotal")} value={total} trend={t("kpiTotalTrend", { count: typeCount })} />
|
||||
<KpiCard label={t("kpiHigh")} value={highProtection} trend={t("kpiHighTrend")} trendColor="risk" />
|
||||
<KpiCard
|
||||
label={t("kpiNoOwner")}
|
||||
value={withoutOwner}
|
||||
trend={withoutOwner > 0 ? t("kpiNoOwnerTrend") : undefined}
|
||||
trendColor="warn"
|
||||
/>
|
||||
<KpiCard label={t("kpiSuppliers")} value={suppliers} trend={t("kpiSuppliersTrend")} />
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||
<FilterTabs
|
||||
tabs={[
|
||||
{ href: typeHref(null), label: t("all"), active: !activeType },
|
||||
...ASSET_TYPES.map((v) => ({
|
||||
href: typeHref(v),
|
||||
label: tType(v),
|
||||
active: activeType === v,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<form method="GET" className="flex max-w-65 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-muted-foreground">
|
||||
{activeType && <input type="hidden" name="type" value={activeType} />}
|
||||
<Search className="size-[15px] shrink-0" />
|
||||
<input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder={t("filter")}
|
||||
className="w-full border-0 bg-transparent text-[13px] text-foreground outline-none"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Asset</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>
|
||||
<span className="flex flex-col gap-1">
|
||||
{t("protection")}
|
||||
<CiaLegend />
|
||||
</span>
|
||||
</TableHead>
|
||||
<TableHead>{t("dependencies")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{assets.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{assets.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell>
|
||||
<Link href={`/assets?detail=${asset.id}`} className="font-bold hover:underline">
|
||||
{asset.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag>{tType(asset.type)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<OwnerChip name={asset.owner?.name} noOwnerLabel={t("noOwner")} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{asset.relationsFrom.length
|
||||
? asset.relationsFrom.map((r) => r.relatedAsset.name).join(", ")
|
||||
: tc("none")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Lieferanten- & IT-Service-Cockpits werden hier direkt gerendert
|
||||
(backHref="/assets"), damit man auf der Asset-Seite bleibt. */}
|
||||
{supplierModal && isEdit ? (
|
||||
<SupplierEditModal supplier={supplierModal} backHref="/assets" />
|
||||
) : supplierModal ? (
|
||||
<SupplierDetailModal supplier={supplierModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : serviceModal && isEdit ? (
|
||||
<ServiceEditModal service={serviceModal} providers={providers} backHref="/assets" />
|
||||
) : serviceModal ? (
|
||||
<ServiceDetailModal service={serviceModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : softwareModal && isEdit ? (
|
||||
<SoftwareEditModal software={softwareModal} providers={providers} backHref="/assets" />
|
||||
) : softwareModal ? (
|
||||
<SoftwareDetailModal software={softwareModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : projectModal && isEdit ? (
|
||||
<ProjectEditModal project={projectModal} users={users} backHref="/assets" />
|
||||
) : projectModal ? (
|
||||
<ProjectDetailModal project={projectModal} canWrite={canWrite} backHref="/assets" />
|
||||
) : params.new && canWrite && activeType === "PROJECT" ? (
|
||||
<ProjectCreateModal users={users} />
|
||||
) : modalAsset && isEdit ? (
|
||||
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
|
||||
) : modalAsset ? (
|
||||
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<AssetCreateModal users={users} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Sparkles, FileText, AlertTriangle } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { buildControlGroups, type ControlGroup, type ControlLamp, type RequirementBullet } from "@/server/control-descriptions-context";
|
||||
import { isAiConfigured } from "@/server/ai/client";
|
||||
import { draftControlDescription, draftControl, confirmDescription, saveDescription, saveOpenAnswer } from "@/server/actions/control-descriptions";
|
||||
|
||||
const LAMP_TONE: Record<ControlLamp, "ok" | "warn" | "risk"> = { green: "ok", amber: "warn", red: "risk" };
|
||||
const LAMP_LABEL: Record<ControlLamp, string> = { green: "vollständig", amber: "in Arbeit", red: "offen" };
|
||||
const CONF_TONE: Record<string, "ok" | "warn" | "mut"> = { high: "ok", medium: "warn", low: "mut" };
|
||||
const CONF_LABEL: Record<string, string> = { high: "hohe Konfidenz", medium: "mittlere Konfidenz", low: "niedrige Konfidenz" };
|
||||
const STATUS_TONE: Record<string, "mut" | "info" | "ok"> = { open: "mut", draft: "info", confirmed: "ok" };
|
||||
const STATUS_LABEL: Record<string, string> = { open: "manuell zu erfassen", draft: "Entwurf", confirmed: "übernommen" };
|
||||
|
||||
/**
|
||||
* Control-Beschreibungen (VDA-ISA-Spalte 4) — Seite des Audit-Wizards.
|
||||
* Links das Control-Verzeichnis mit Status-Ampel, rechts je Control die
|
||||
* Kontrollfrage, die Anforderungs-Bullets (aus PolicyRequirement) und je Bullet
|
||||
* ein KI-Entwurf-Kasten (Text, Quelle, Konfidenz, Aktionen Übernehmen/Anpassen/
|
||||
* Neu). Ohne Beleg: „kein Beleg gefunden" + gezielte Rückfrage (openAnswer).
|
||||
* Die Wizard-Shell/Navigation liefert V5A; hier nur diese Seite.
|
||||
*/
|
||||
export default async function ControlsPage({ params, searchParams }: { params: Promise<{ auditId: string }>; searchParams: Promise<{ control?: string }> }) {
|
||||
const session = await requireSession();
|
||||
const { auditId } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const audit = await db.audit.findFirst({ where: { id: auditId } });
|
||||
if (!audit) notFound();
|
||||
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
const aiOn = isAiConfigured();
|
||||
const groups = await buildControlGroups(db);
|
||||
|
||||
const sp = await searchParams;
|
||||
const selected: ControlGroup | undefined = groups.find((g) => g.control === sp.control) ?? groups[0];
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Audit · ${audit.title}`}
|
||||
title="Control-Beschreibungen (VDA ISA)"
|
||||
sub="Umsetzungsbeschreibung je Anforderung — KI-Entwurf, prüfen, übernehmen."
|
||||
actions={
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/audit-readiness/${auditId}/export`} />}>
|
||||
<FileText className="size-3.5" /> ABGABE-Vorschau
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{!aiOn && (
|
||||
<div className="mb-4 flex items-start gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>KI-Entwurf ist nicht konfiguriert (kein <code>ANTHROPIC_API_KEY</code>). Beschreibungen können manuell erfasst werden; die übrige Funktion steht bereit.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine Anforderungen vorhanden — zunächst das Richtlinienpaket importieren.</p>
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[280px_1fr]">
|
||||
{/* Control-Verzeichnis mit Status-Ampel */}
|
||||
<ol className="shadow-card h-fit space-y-1 rounded-xl border bg-card p-2">
|
||||
{groups.map((g) => {
|
||||
const isCurrent = selected?.control === g.control;
|
||||
return (
|
||||
<li key={g.control}>
|
||||
<Link
|
||||
href={`/audit-readiness/${auditId}/controls?control=${g.control}`}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] hover:opacity-90 ${isCurrent ? "bg-[var(--surface-soft)] font-semibold" : ""}`}
|
||||
>
|
||||
<span className={`size-2.5 shrink-0 rounded-full ${g.lamp === "green" ? "bg-[var(--ok)]" : g.lamp === "amber" ? "bg-[var(--warn)]" : "bg-[var(--risk)]"}`} aria-label={LAMP_LABEL[g.lamp]} />
|
||||
<span className="flex-1 truncate">{g.control} · {g.frage}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Detail des gewählten Controls */}
|
||||
{selected && (
|
||||
<section className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="font-heading text-lg font-semibold">{selected.control} · {selected.frage}</h2>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">{selected.bullets.length} Anforderung(en) · {selected.evidence.length} Nachweis(e)</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone={LAMP_TONE[selected.lamp]}>{LAMP_LABEL[selected.lamp]}</Pill>
|
||||
{canUse && aiOn && (
|
||||
<form action={draftControl.bind(null, selected.control)}>
|
||||
<Button type="submit" size="sm"><Sparkles className="size-3.5" /> Alle entwerfen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{selected.bullets.map((b) => (
|
||||
<BulletCard key={b.reqId} bullet={b} canUse={canUse} aiOn={aiOn} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!canUse && <p className="text-[12.5px] text-muted-foreground">Nur Lesezugriff.</p>}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function BulletCard({ bullet, canUse, aiOn }: { bullet: RequirementBullet; canUse: boolean; aiOn: boolean }) {
|
||||
const d = bullet.description;
|
||||
const status = d?.status ?? "open";
|
||||
return (
|
||||
<article className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone={bullet.obligation === "MUSS" ? "risk" : "info"}>{bullet.obligation}</Pill>
|
||||
<span className="text-[11px] font-mono text-muted-foreground">{bullet.reqId}</span>
|
||||
<Pill tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Pill>
|
||||
</div>
|
||||
<p className="mt-2 text-[13px] font-medium">{bullet.requirement}</p>
|
||||
{bullet.implementation && <p className="mt-1 text-[12px] text-muted-foreground">Umsetzungshinweis: {bullet.implementation}</p>}
|
||||
{bullet.documents.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{bullet.documents.map((doc) => (
|
||||
<Pill key={doc.code} tone={doc.status === "FREIGEGEBEN" ? "ok" : "mut"}>{doc.code} · v{doc.version}</Pill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Entwurf-Kasten */}
|
||||
<div className="mt-3 rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
{d?.draftText ? (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[11.5px] font-semibold text-muted-foreground">Beschreibung der Umsetzung</span>
|
||||
{d.confidence && <Pill tone={CONF_TONE[d.confidence] ?? "mut"}>{CONF_LABEL[d.confidence] ?? d.confidence}</Pill>}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[13px] whitespace-pre-wrap">{d.draftText}</p>
|
||||
{d.sourceRef && <p className="mt-1.5 text-[11.5px] text-muted-foreground">Quelle: {d.sourceRef}</p>}
|
||||
{canUse && (
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2 border-t pt-2.5">
|
||||
{status !== "confirmed" && (
|
||||
<form action={confirmDescription.bind(null, bullet.reqId)}>
|
||||
<Button type="submit" size="sm">Übernehmen</Button>
|
||||
</form>
|
||||
)}
|
||||
<EditForm bullet={bullet} />
|
||||
{aiOn && (
|
||||
<form action={draftControlDescription.bind(null, bullet.reqId)}>
|
||||
<Button type="submit" size="sm" variant="outline"><Sparkles className="size-3.5" /> Neu</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : bullet.hasEvidence ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-[12px] text-muted-foreground">Noch kein Entwurf. {aiOn ? "KI-Entwurf aus verknüpften Belegen erzeugen." : "Manuell erfassen."}</span>
|
||||
{canUse && aiOn && (
|
||||
<form action={draftControlDescription.bind(null, bullet.reqId)}>
|
||||
<Button type="submit" size="sm"><Sparkles className="size-3.5" /> KI-Entwurf</Button>
|
||||
</form>
|
||||
)}
|
||||
{canUse && <EditForm bullet={bullet} />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex items-start gap-1.5 text-[12px] text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-3.5 shrink-0 text-warn" /> Kein Beleg gefunden. Bitte gezielte Rückfrage beantworten:
|
||||
</p>
|
||||
{canUse ? (
|
||||
<form action={saveOpenAnswer.bind(null, bullet.reqId)} className="mt-2 space-y-2">
|
||||
<Textarea name="openAnswer" rows={2} defaultValue={d?.openAnswer ?? ""} placeholder="Wie/wo ist diese Anforderung umgesetzt? (Dokument, Abschnitt, Nachweis)" />
|
||||
<Button type="submit" size="sm" variant="secondary">Antwort speichern</Button>
|
||||
</form>
|
||||
) : (
|
||||
d?.openAnswer && <p className="mt-1.5 text-[12px]">{d.openAnswer}</p>
|
||||
)}
|
||||
{canUse && <div className="mt-2"><EditForm bullet={bullet} /></div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Anpassen" — Beschreibung manuell bearbeiten/erfassen (aufklappbar). */
|
||||
function EditForm({ bullet }: { bullet: RequirementBullet }) {
|
||||
const d = bullet.description;
|
||||
return (
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center rounded-md border border-input px-3 text-[13px] font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||||
Anpassen
|
||||
</summary>
|
||||
<form action={saveDescription.bind(null, bullet.reqId)} className="shadow-card absolute right-0 z-10 mt-2 w-96 space-y-2 rounded-xl border bg-card p-3">
|
||||
<Textarea name="draftText" rows={4} defaultValue={d?.draftText ?? ""} placeholder="Umsetzungssatz inkl. Dokumentverweis …" />
|
||||
<Input name="sourceRef" defaultValue={d?.sourceRef ?? ""} placeholder="Quelle (Dokument, Version, Abschnitt)" />
|
||||
<select name="confidence" defaultValue={d?.confidence ?? "medium"} className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||
<option value="high">hohe Konfidenz</option>
|
||||
<option value="medium">mittlere Konfidenz</option>
|
||||
<option value="low">niedrige Konfidenz</option>
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="secondary">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { Download, ListChecks } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildAbgabeRows } from "@/server/export-context";
|
||||
import { PRUEFZIEL_LABEL } from "@/lib/export/vda-isa";
|
||||
|
||||
/**
|
||||
* ABGABE-Vorschau (VDA-ISA-Prüfungsdokumentation) — Seite des Audit-Wizards.
|
||||
* Zeigt je Control den zusammengesetzten „Beschreibung der Umsetzung"-Text
|
||||
* (übernommene ControlDescription je Anforderung), den bestätigten Reifegrad
|
||||
* und die Referenz-Dokumentation. Download über die geteilte Export-Route als CSV
|
||||
* (`?format=abgabe`) oder als echte xlsx (`?format=abgabe-xlsx`).
|
||||
*/
|
||||
export default async function AbgabePreviewPage({ params }: { params: Promise<{ auditId: string }> }) {
|
||||
const session = await requireSession();
|
||||
const { auditId } = await params;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const audit = await db.audit.findFirst({ where: { id: auditId } });
|
||||
if (!audit) notFound();
|
||||
|
||||
const rows = await buildAbgabeRows(db, session.user.tenantId);
|
||||
const withText = rows.filter((r) => r.umsetzung).length;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Audit · ${audit.title}`}
|
||||
title="ABGABE-Vorschau (VDA ISA)"
|
||||
sub={`${withText} von ${rows.length} Controls mit übernommener Umsetzungsbeschreibung.`}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/audit-readiness/${auditId}/controls`} />}>
|
||||
<ListChecks className="size-3.5" /> Zu den Beschreibungen
|
||||
</Button>
|
||||
{/* Datei-Download über einen Route-Handler (kein Page-Route) — bewusst `<a>`, nicht `<Link>`. */}
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<a href="/audit-readiness/export?format=abgabe" />}>
|
||||
<Download className="size-3.5" /> ABGABE (CSV)
|
||||
</Button>
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<a href="/audit-readiness/export?format=abgabe-xlsx" />}>
|
||||
<Download className="size-3.5" /> ABGABE (xlsx)
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine Controls vorhanden.</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{rows.map((r) => (
|
||||
<section key={r.control} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-heading text-sm font-semibold">{r.control} · {r.frage}</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="info">{PRUEFZIEL_LABEL[r.pruefziel]}</Pill>
|
||||
<Pill tone={r.reifegrad === null ? "mut" : "ok"}>
|
||||
Reifegrad {r.reifegrad ?? "—"}{r.zielReifegrad !== null ? ` / Ziel ${r.zielReifegrad}` : ""}
|
||||
</Pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{r.umsetzung ? (
|
||||
<p className="mt-2 text-[12.5px] whitespace-pre-wrap">{r.umsetzung}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-[12px] text-muted-foreground">Noch keine übernommene Beschreibung — im Beschreibungs-Schritt erzeugen und übernehmen.</p>
|
||||
)}
|
||||
|
||||
{r.referenzen && (
|
||||
<p className="mt-2 border-t pt-2 text-[11.5px] text-muted-foreground">
|
||||
<span className="font-semibold">Referenz Dokumentation:</span> {r.referenzen}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-[11.5px] text-muted-foreground">
|
||||
Download als CSV (DE-Excel, semikolongetrennt) oder als xlsx im VDA-ISA-ABGABE-Layout.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { ArrowLeft, CalendarDays, Users } from "lucide-react";
|
||||
import type { AuditStatus } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { WizardTabs } from "./wizard-tabs";
|
||||
|
||||
const STATUS_LABEL: Record<AuditStatus, string> = { PLANNED: "Geplant", IN_PREPARATION: "In Vorbereitung", DONE: "Abgeschlossen" };
|
||||
const STATUS_TONE: Record<AuditStatus, "mut" | "warn" | "ok"> = { PLANNED: "mut", IN_PREPARATION: "warn", DONE: "ok" };
|
||||
|
||||
/**
|
||||
* Wizard-Shell eines **externen** Audits (V5A). Lädt das Audit (mandantengebunden),
|
||||
* rendert Kopf (Titel/Typ/Termin/Status/Scope) + Tab-Navigation und umschließt die
|
||||
* Sub-Routen (Nachweise/Controls/Readiness/Abgabe). Interne Audits haben keinen Wizard
|
||||
* (nur Planung/Terminierung) → Redirect zur Übersicht. Das übergeordnete
|
||||
* `/audit-readiness/layout.tsx` gate't zusätzlich die Modul-Scharfschaltung.
|
||||
*/
|
||||
export default async function AuditWizardLayout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ auditId: string }>;
|
||||
}) {
|
||||
const { auditId } = await params;
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const audit = await db.audit.findUnique({ where: { id: auditId } });
|
||||
if (!audit) notFound();
|
||||
if (audit.type !== "EXTERNAL") redirect("/audit-readiness");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/audit-readiness" className="mb-2 inline-flex items-center gap-1 text-[12px] text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-3.5" /> Zur Übersicht
|
||||
</Link>
|
||||
|
||||
<PageHead
|
||||
crumb="Audit · Vorbereitung"
|
||||
title={audit.title}
|
||||
sub="Externe Audit-Vorbereitung — Nachweise, Control-Beschreibungen, Readiness und Abgabe."
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{audit.assessmentLevel && <Pill tone="info">{audit.assessmentLevel}</Pill>}
|
||||
<Pill tone={STATUS_TONE[audit.status]}>{STATUS_LABEL[audit.status]}</Pill>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11.5px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><CalendarDays className="size-3.5" /> {audit.plannedDate ?? "kein Termin"}</span>
|
||||
{audit.provider && <span className="inline-flex items-center gap-1"><Users className="size-3.5" /> {audit.provider}</span>}
|
||||
{audit.scope && <span>Scope: {audit.scope}</span>}
|
||||
</div>
|
||||
|
||||
<WizardTabs auditId={audit.id} />
|
||||
|
||||
<div className="mt-5">{children}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FileCheck2, ListChecks, Upload } from "lucide-react";
|
||||
import type { AuditEvidenceItem } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { controlTitle } from "@/lib/control-titles";
|
||||
import { getFunctionDef } from "@/lib/onboarding/functions";
|
||||
import {
|
||||
generateEvidenceList,
|
||||
createEvidenceTask,
|
||||
createEvidenceTasksForAudit,
|
||||
reassignEvidenceItem,
|
||||
uploadEvidenceFile,
|
||||
} from "@/server/actions/audit-evidence";
|
||||
import { ReassignSelect } from "./reassign-select";
|
||||
|
||||
/**
|
||||
* Nachweise-Tab der Audit-Vorbereitung (V5B). Listet je In-Scope-Control den
|
||||
* bereitzustellenden Nachweis, den automatisch zugeordneten Ansprechpartner
|
||||
* (umhängbar), Nachweis-Aufgaben (`evidence_provide`) und den direkten Datei-Upload.
|
||||
* Die Wizard-Shell/Navigation liefert V5A — hier nur der Tab-Inhalt.
|
||||
*/
|
||||
|
||||
const STATUS_TONE = { offen: "warn", bereitgestellt: "ok", ueberfaellig: "risk" } as const;
|
||||
const STATUS_LABEL = { offen: "offen", bereitgestellt: "bereitgestellt", ueberfaellig: "überfällig" } as const;
|
||||
type ItemStatus = keyof typeof STATUS_TONE;
|
||||
const statusTone = (s: string) => STATUS_TONE[s as ItemStatus] ?? "mut";
|
||||
const statusLabel = (s: string) => STATUS_LABEL[s as ItemStatus] ?? s;
|
||||
|
||||
export default async function NachweiseTab({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ auditId: string }>;
|
||||
searchParams: Promise<{ view?: string }>;
|
||||
}) {
|
||||
const { auditId } = await params;
|
||||
const { view } = await searchParams;
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const me = session.user.id;
|
||||
|
||||
const audit = await db.audit.findUnique({ where: { id: auditId } });
|
||||
if (!audit) notFound();
|
||||
|
||||
// Konsistent zum übrigen Audit-Feature (audits.ts / control-descriptions.ts): onboarding:use.
|
||||
const canManage = hasPermission(session, "onboarding:use");
|
||||
const canSeeAll = hasPermission(session, "task:read_all");
|
||||
|
||||
const [items, users, myFunctions] = await Promise.all([
|
||||
db.auditEvidenceItem.findMany({ where: { auditId }, orderBy: { control: "asc" } }),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.projectFunctionAssignment.findMany({ where: { userId: me }, select: { functionKey: true } }),
|
||||
]);
|
||||
const userName = new Map(users.map((u) => [u.id, u.name]));
|
||||
const myFunctionKeys = new Set(myFunctions.map((f) => f.functionKey));
|
||||
|
||||
// Sicht-Filter: PM+ISB (task:read_all) sehen alle; sonst Default „Meine".
|
||||
const effectiveView = view === "all" || view === "mine" ? view : canSeeAll ? "all" : "mine";
|
||||
const isMine = (it: AuditEvidenceItem) =>
|
||||
it.assignedUserId === me || (it.assignedFunctionKey ? myFunctionKeys.has(it.assignedFunctionKey) : false);
|
||||
const showAll = effectiveView === "all" && canSeeAll;
|
||||
const visible = showAll ? items : items.filter(isMine);
|
||||
|
||||
const openCount = items.filter((i) => i.status === "offen").length;
|
||||
const providedCount = items.filter((i) => i.status === "bereitgestellt").length;
|
||||
const withoutTask = items.filter((i) => i.status === "offen" && !i.taskId).length;
|
||||
|
||||
const base = `/audit-readiness/${auditId}/nachweise`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Audit-Vorbereitung · ${audit.title}`}
|
||||
title="Nachweise"
|
||||
sub="Bereitzustellende Nachweise je In-Scope-Control — Ansprechpartner, Aufgaben und Datei-Upload."
|
||||
/>
|
||||
|
||||
{/* KPIs + Aktionen */}
|
||||
<div className="mt-4 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[12px]">
|
||||
<Pill tone="info">gesamt: {items.length}</Pill>
|
||||
<Pill tone="warn">offen: {openCount}</Pill>
|
||||
<Pill tone="ok">bereitgestellt: {providedCount}</Pill>
|
||||
{withoutTask > 0 && <Pill tone="mut">ohne Aufgabe: {withoutTask}</Pill>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<form action={generateEvidenceList.bind(null, auditId)}>
|
||||
<Button type="submit" variant={items.length === 0 ? "default" : "outline"} size="sm">
|
||||
<ListChecks className="size-4" /> Nachweisliste generieren
|
||||
</Button>
|
||||
</form>
|
||||
{withoutTask > 0 && (
|
||||
<form action={createEvidenceTasksForAudit.bind(null, auditId)}>
|
||||
<Button type="submit" size="sm" variant="outline">Aufgaben für offene erzeugen ({withoutTask})</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Die Nachweisart stammt aus den Umsetzungshinweisen je Control (Fallback: generisch). Der Ansprechpartner
|
||||
wird automatisch aus dem zuständigen Bereich/der Funktion abgeleitet und kann umgehängt werden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sicht-Filter */}
|
||||
{canSeeAll && (
|
||||
<div className="mt-4 flex items-center gap-2 text-[12.5px]">
|
||||
<span className="text-muted-foreground">Sicht:</span>
|
||||
<Link href={`${base}?view=all`} className={`rounded-md border px-2.5 py-1 ${showAll ? "bg-[var(--surface-soft)] font-semibold" : "hover:bg-muted"}`}>Alle</Link>
|
||||
<Link href={`${base}?view=mine`} className={`rounded-md border px-2.5 py-1 ${!showAll ? "bg-[var(--surface-soft)] font-semibold" : "hover:bg-muted"}`}>Meine</Link>
|
||||
</div>
|
||||
)}
|
||||
{!canSeeAll && (
|
||||
<p className="mt-4 text-[12.5px] text-muted-foreground">Es werden nur die Nachweise gezeigt, für die Sie zuständig sind.</p>
|
||||
)}
|
||||
|
||||
{/* Liste */}
|
||||
<div className="mt-4 rounded-xl border bg-card">
|
||||
{items.length === 0 ? (
|
||||
<div className="p-8 text-center text-[13px] text-muted-foreground">
|
||||
<FileCheck2 className="mx-auto mb-2 size-6 opacity-60" />
|
||||
Noch keine Nachweisliste. {canManage ? "Über „Nachweisliste generieren“ je In-Scope-Control eine Position anlegen." : "Bitte von der Projektsteuerung generieren lassen."}
|
||||
</div>
|
||||
) : visible.length === 0 ? (
|
||||
<div className="p-8 text-center text-[13px] text-muted-foreground">
|
||||
Keine Nachweise in dieser Sicht{effectiveView === "mine" ? " — Ihnen ist aktuell keine Position zugeordnet." : "."}
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{visible.map((it) => {
|
||||
const fn = it.assignedFunctionKey ? getFunctionDef(it.assignedFunctionKey) : undefined;
|
||||
const provided = it.status === "bereitgestellt";
|
||||
return (
|
||||
<li key={it.id} className="border-b p-4 last:border-0">
|
||||
<div className="grid gap-3 lg:grid-cols-[1fr_240px_260px]">
|
||||
{/* Control + Nachweisart */}
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone="info">{it.control}</Pill>
|
||||
<Pill tone={statusTone(it.status)}>{statusLabel(it.status)}</Pill>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[13px] font-medium">{controlTitle(it.control)}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">Nachweisart: {it.title}</p>
|
||||
</div>
|
||||
|
||||
{/* Ansprechpartner */}
|
||||
<div className="text-[12.5px]">
|
||||
<p className="mb-1 text-[11.5px] text-muted-foreground">
|
||||
Ansprechpartner{fn ? ` · ${fn.label}` : it.assignedFunctionKey ? ` · ${it.assignedFunctionKey}` : ""}
|
||||
</p>
|
||||
{canManage ? (
|
||||
<ReassignSelect
|
||||
action={reassignEvidenceItem.bind(null, it.id)}
|
||||
currentUserId={it.assignedUserId}
|
||||
users={users}
|
||||
disabled={provided}
|
||||
/>
|
||||
) : (
|
||||
<span>{it.assignedUserId ? userName.get(it.assignedUserId) ?? "—" : "— offen —"}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aufgabe + Upload */}
|
||||
<div className="space-y-2 text-[12.5px]">
|
||||
{it.taskId ? (
|
||||
<Link href="/tasks" className="inline-flex items-center gap-1 text-[var(--brand,inherit)] underline underline-offset-2">Nachweis-Aufgabe ansehen</Link>
|
||||
) : canManage && !provided ? (
|
||||
<form action={createEvidenceTask.bind(null, it.id)}>
|
||||
<Button type="submit" size="sm" variant="outline">Aufgabe erzeugen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<span className="text-muted-foreground">keine Aufgabe</span>
|
||||
)}
|
||||
|
||||
{provided ? (
|
||||
<p className="flex items-center gap-1 text-[var(--ok)]"><FileCheck2 className="size-3.5" /> Nachweis abgelegt</p>
|
||||
) : canManage ? (
|
||||
<form action={uploadEvidenceFile.bind(null, it.id)} className="flex items-center gap-2">
|
||||
<input type="file" name="file" required accept=".pdf,.docx,.odt" className="min-w-0 flex-1 text-[11.5px] file:mr-2 file:rounded file:border file:bg-muted file:px-2 file:py-1 file:text-[11.5px]" />
|
||||
<Button type="submit" size="sm"><Upload className="size-3.5" /> Upload</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Datei-Ablage über den gekapselten Storage-Adapter (Phase 1: Stub — Metadaten/Key; echte MinIO-Byte-Speicherung folgt).
|
||||
Ein Upload erzeugt einen Nachweis, verknüpft ihn mit der Position, setzt den Status auf „bereitgestellt“ und schließt die Aufgabe.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Ansprechpartner-Umhängen als selbst-absendendes Dropdown. Ändert der Bearbeiter
|
||||
* die Auswahl, wird die gebundene Server-Action (`reassignEvidenceItem`) direkt
|
||||
* ausgelöst — ohne separaten Submit-Button. Reine UI-Bequemlichkeit; die
|
||||
* Autorisierung passiert serverseitig in der Action.
|
||||
*/
|
||||
export function ReassignSelect({
|
||||
action,
|
||||
currentUserId,
|
||||
users,
|
||||
disabled,
|
||||
}: {
|
||||
action: (formData: FormData) => void;
|
||||
currentUserId: string | null;
|
||||
users: { id: string; name: string }[];
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<form action={action}>
|
||||
<select
|
||||
name="userId"
|
||||
defaultValue={currentUserId ?? ""}
|
||||
disabled={disabled}
|
||||
onChange={(e) => e.currentTarget.form?.requestSubmit()}
|
||||
className="w-full rounded-md border bg-background px-2 py-1 text-[12.5px] disabled:opacity-60"
|
||||
>
|
||||
<option value="">— offen (nicht besetzt) —</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
/**
|
||||
* Wizard-Einstieg eines externen Audits (V5A): leitet auf den ersten Tab „Nachweise"
|
||||
* um. Die Nachweis-Seite selbst liefert V5B; bis dahin ist der Readiness-Tab
|
||||
* (`/readiness`) direkt erreichbar. Reihenfolge der Tabs: Nachweise → Controls →
|
||||
* Readiness → Abgabe.
|
||||
*/
|
||||
export default async function AuditWizardIndexPage({ params }: { params: Promise<{ auditId: string }> }) {
|
||||
const { auditId } = await params;
|
||||
redirect(`/audit-readiness/${auditId}/nachweise`);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ClipboardCheck, Info } from "lucide-react";
|
||||
import type { AuditStatus } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { ReadinessStep } from "@/app/(app)/audit-readiness/steps/readiness/step";
|
||||
import { GapStep } from "@/app/(app)/audit-readiness/steps/gap/step";
|
||||
|
||||
const STATUS_LABEL: Record<AuditStatus, string> = { PLANNED: "Geplant", IN_PREPARATION: "In Vorbereitung", DONE: "Abgeschlossen" };
|
||||
|
||||
/**
|
||||
* Readiness- & GAP-Tab der externen Audit-Vorbereitung (V5A). Wiederverwendung der
|
||||
* bestehenden M4-Logik: Reifegrad je Kapitel (Ist/Ziel) + Nächste Schritte über
|
||||
* `ReadinessStep` (nutzt `readiness.ts` + `buildControlRows`), konsolidierter
|
||||
* GAP-/Maßnahmenplan über `GapStep`. Ergänzt um den **Rückfluss der Feststellungen
|
||||
* aus internen Audits**: abgeschlossene interne Audits speisen ihre Ergebnisse als
|
||||
* Eingangsgröße in die Readiness des externen Audits ein.
|
||||
*/
|
||||
export default async function AuditReadinessTabPage() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
// Rückfluss: Feststellungen aus internen Audits (Ergebnis gepflegt, kein Turnus-Merker).
|
||||
const internalAudits = await db.audit.findMany({
|
||||
where: { type: "INTERNAL" },
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
});
|
||||
const findings = internalAudits.filter((a) => a.result && !a.result.startsWith("Turnus: "));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Rückfluss interne Audits */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<ClipboardCheck className="size-4" /> Feststellungen aus internen Audits
|
||||
</span>
|
||||
<Pill tone="info">{findings.length}</Pill>
|
||||
</div>
|
||||
{findings.length === 0 ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[12.5px] text-muted-foreground">
|
||||
<Info className="mt-0.5 size-4 shrink-0" />
|
||||
Noch keine Feststellungen. Sobald ein internes Audit mit Ergebnis abgeschlossen ist, fließen dessen Feststellungen hier in die Readiness ein.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{findings.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border border-l-[3px] border-l-[var(--warn)] bg-[var(--surface-soft)] p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone={a.status === "DONE" ? "ok" : "warn"}>{STATUS_LABEL[a.status]}</Pill>
|
||||
<span className="text-[12.5px] font-medium">{a.title}</span>
|
||||
{a.plannedDate && <span className="text-[11px] text-muted-foreground">· {a.plannedDate}</span>}
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] whitespace-pre-line text-muted-foreground">{a.result}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Reifegrad je Kapitel (Ist/Ziel) + nächste Schritte (M4-Readiness) */}
|
||||
<ReadinessStep />
|
||||
|
||||
{/* Offene GAPs / konsolidierter Maßnahmenplan (M4-Gap) */}
|
||||
<GapStep />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { FileCheck2, FileText, Gauge, Send } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Tab-Navigation der Wizard-Shell eines externen Audits (V5A). Reine Navigation zu den
|
||||
* Sub-Routen; die aktive Markierung folgt dem Pfad (`usePathname`). Die Seiten für
|
||||
* Nachweise/Controls/Abgabe liefern V5B/V5C — hier stehen nur die Links.
|
||||
*/
|
||||
const TABS = [
|
||||
{ seg: "nachweise", label: "Nachweise", icon: FileText },
|
||||
{ seg: "controls", label: "Control-Beschreibungen", icon: FileCheck2 },
|
||||
{ seg: "readiness", label: "Readiness & GAP", icon: Gauge },
|
||||
{ seg: "export", label: "Abgabe", icon: Send },
|
||||
] as const;
|
||||
|
||||
export function WizardTabs({ auditId }: { auditId: string }) {
|
||||
const pathname = usePathname();
|
||||
const base = `/audit-readiness/${auditId}`;
|
||||
|
||||
return (
|
||||
<nav className="flex flex-wrap gap-1 border-b">
|
||||
{TABS.map(({ seg, label, icon: Icon }) => {
|
||||
const href = `${base}/${seg}`;
|
||||
const active = pathname === href || pathname.startsWith(`${href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={seg}
|
||||
href={href}
|
||||
className={`-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-[12.5px] font-medium transition-colors ${
|
||||
active
|
||||
? "border-[var(--primary)] text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<Icon className="size-3.5" /> {label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { CalendarPlus, Pencil, X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
export interface AuditFormValues {
|
||||
id: string;
|
||||
type: "INTERNAL" | "EXTERNAL";
|
||||
title: string;
|
||||
plannedDate: string | null;
|
||||
scope: string | null;
|
||||
assessmentLevel: string | null;
|
||||
provider: string | null;
|
||||
auditorUserId: string | null;
|
||||
interval: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup zum Anlegen bzw. Bearbeiten eines Audits (V5A). Overlay-/Karten-Optik bewusst
|
||||
* identisch zum bestehenden `Modal`/`Popup`. Der Typ (intern/extern) schaltet lokal die
|
||||
* jeweils passenden Felder frei: extern → Assessment-Level + Prüfdienstleister,
|
||||
* intern → Auditor + Turnus. Gespeichert wird über die als `action` übergebene
|
||||
* Server-Action (`createAudit` bzw. `updateAudit.bind(null, id)`), die anschließend
|
||||
* navigiert (Redirect) — daher kein manuelles Schließen nach dem Submit nötig.
|
||||
*/
|
||||
export function AuditDialog({
|
||||
action,
|
||||
users,
|
||||
audit,
|
||||
mode,
|
||||
}: {
|
||||
action: (formData: FormData) => void | Promise<void>;
|
||||
users: { id: string; name: string | null }[];
|
||||
audit?: AuditFormValues;
|
||||
mode: "create" | "edit";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [type, setType] = useState<"INTERNAL" | "EXTERNAL">(audit?.type ?? "EXTERNAL");
|
||||
const initialInterval = audit?.interval ?? "";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const isExternal = type === "EXTERNAL";
|
||||
const selectCls = "h-8 w-full rounded-md border border-input bg-background px-2 text-[12.5px]";
|
||||
const labelCls = "mb-1 block text-[12px] font-medium text-muted-foreground";
|
||||
|
||||
return (
|
||||
<>
|
||||
{mode === "create" ? (
|
||||
<Button type="button" onClick={() => setOpen(true)}>
|
||||
<CalendarPlus className="size-4" /> Neues Audit planen
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(true)}>
|
||||
<Pencil className="size-3.5" /> Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="shadow-card my-auto w-full max-w-lg rounded-2xl border bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">
|
||||
{mode === "create" ? "Neues Audit planen" : "Audit bearbeiten"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
|
||||
Intern = anlegen & terminieren. Extern = Vorbereitung im Wizard.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Schließen"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form action={action} className="space-y-4 p-5">
|
||||
{/* Typ */}
|
||||
<div>
|
||||
<span className={labelCls}>Audit-Typ</span>
|
||||
<div className="flex gap-2">
|
||||
{(["INTERNAL", "EXTERNAL"] as const).map((v) => (
|
||||
<label
|
||||
key={v}
|
||||
className={`flex flex-1 cursor-pointer items-center justify-center gap-2 rounded-lg border px-3 py-2 text-[12.5px] ${
|
||||
type === v ? "border-[var(--primary)] bg-[var(--surface-soft)] font-semibold" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="type"
|
||||
value={v}
|
||||
checked={type === v}
|
||||
onChange={() => setType(v)}
|
||||
className="accent-[var(--primary)]"
|
||||
/>
|
||||
{v === "INTERNAL" ? "Internes Audit" : "Externes Audit"}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-title">Titel</label>
|
||||
<Input id="audit-title" name="title" required defaultValue={audit?.title ?? ""} placeholder="z. B. TISAX-Assessment 2026" className="h-8 text-[12.5px]" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-date">Termin</label>
|
||||
<Input id="audit-date" name="plannedDate" defaultValue={audit?.plannedDate ?? ""} placeholder="z. B. 24.–25.09.2026" className="h-8 text-[12.5px]" />
|
||||
</div>
|
||||
{isExternal ? (
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-level">Assessment-Level</label>
|
||||
<select id="audit-level" name="assessmentLevel" defaultValue={audit?.assessmentLevel ?? ""} className={selectCls}>
|
||||
<option value="">— wählen —</option>
|
||||
<option value="AL2">AL2</option>
|
||||
<option value="AL3">AL3</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-interval">Turnus</label>
|
||||
<Input id="audit-interval" name="interval" defaultValue={initialInterval} placeholder="z. B. jährlich" className="h-8 text-[12.5px]" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExternal ? (
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-provider">Prüfdienstleister</label>
|
||||
<Input id="audit-provider" name="provider" defaultValue={audit?.provider ?? ""} placeholder="z. B. DEKRA, TÜV …" className="h-8 text-[12.5px]" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-auditor">Auditor (unabhängig)</label>
|
||||
<select id="audit-auditor" name="auditorUserId" defaultValue={audit?.auditorUserId ?? ""} className={selectCls}>
|
||||
<option value="">— wählen —</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name ?? u.id}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className={labelCls} htmlFor="audit-scope">Scope / Geltungsbereich</label>
|
||||
<Textarea id="audit-scope" name="scope" rows={2} defaultValue={audit?.scope ?? ""} placeholder="Standorte, Prüfziele, Bereiche …" className="text-[12.5px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 border-t pt-3">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)}>Abbrechen</Button>
|
||||
<Button type="submit" size="sm">{mode === "create" ? "Audit anlegen" : "Speichern"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { buildExportRows, buildAbgabeRows } from "@/server/export-context";
|
||||
import { toCatalogCsv, toAbgabeCsv } from "@/lib/export/vda-isa";
|
||||
import { buildAbgabeXlsx } from "@/server/export/vda-isa-xlsx";
|
||||
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
|
||||
|
||||
/**
|
||||
* VDA-ISA-Export als CSV-Download (Excel-öffenbar). Zwei Sichten über `?format`:
|
||||
* - `katalog` (Default, Story B7-2, C9 §3): Reifegrad-Überblick je Control aus
|
||||
* Control-Assessment (M3) und Gap-Liste; „unbestätigt" markiert.
|
||||
* - `abgabe`: Prüfungsdokumentation je Control — bestätigter Reifegrad,
|
||||
* zusammengesetzte Umsetzungsbeschreibung (übernommene ControlDescription je
|
||||
* Anforderung) und Referenz-Dokumentation (Dokumentverweise + Nachweise).
|
||||
* - `abgabe-xlsx`: dieselbe ABGABE-Sicht, aber als echte xlsx (VDA-ISA-Layout,
|
||||
* fette Kopfzeile, sinnvolle Spaltenbreiten) statt CSV.
|
||||
* Alle: Reihenfolge Informationssicherheit → Prototyp → Datenschutz.
|
||||
*
|
||||
* Teil des Audit-Wizards (M4). Route-Handler laufen NICHT durch das Layout-Gate —
|
||||
* daher wird die Scharfschaltung des Audit-Wizards hier eigenständig geprüft.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "onboarding:use")) {
|
||||
return new Response("Nicht berechtigt.", { status: 403 });
|
||||
}
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
if (!(await isAuditReadinessEnabled(db))) {
|
||||
return new Response("Audit-Wizard ist nicht aktiviert.", { status: 404 });
|
||||
}
|
||||
|
||||
const format = new URL(req.url).searchParams.get("format");
|
||||
|
||||
// ABGABE als echte xlsx (binär) — eigener Response-Zweig mit passendem MIME-Typ.
|
||||
if (format === "abgabe-xlsx") {
|
||||
const buffer = await buildAbgabeXlsx(await buildAbgabeRows(db, session.user.tenantId));
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="vda-isa-abgabe.xlsx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const { csv, filename } =
|
||||
format === "abgabe"
|
||||
? { csv: toAbgabeCsv(await buildAbgabeRows(db, session.user.tenantId)), filename: "vda-isa-abgabe.csv" }
|
||||
: { csv: toCatalogCsv(await buildExportRows(db, session.user.tenantId)), filename: "vda-isa-katalog.csv" };
|
||||
|
||||
return new Response(csv, {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { isAuditReadinessEnabled } from "@/lib/audit-readiness/activation";
|
||||
|
||||
/**
|
||||
* Gate des Audit-Wizards (M4): Der Wizard ist aus dem Onboarding herausgelöst und
|
||||
* opt-in (Default = aus). Nur bei ausdrücklicher Scharfschaltung ist die Route
|
||||
* erreichbar — sonst Redirect auf das Dashboard.
|
||||
*/
|
||||
export default async function AuditReadinessLayout({ children }: { children: React.ReactNode }) {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
if (!(await isAuditReadinessEnabled(db))) redirect("/dashboard?module=disabled");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import Link from "next/link";
|
||||
import { CalendarDays, ClipboardCheck, FileSearch, Users } from "lucide-react";
|
||||
import type { AuditStatus, AuditType } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createAudit, deleteAudit, updateAudit } from "@/server/actions/audits";
|
||||
import { AuditDialog, type AuditFormValues } from "./audit-dialog";
|
||||
|
||||
const TYPE_LABEL: Record<AuditType, string> = { INTERNAL: "Intern", EXTERNAL: "Extern" };
|
||||
const STATUS_LABEL: Record<AuditStatus, string> = { PLANNED: "Geplant", IN_PREPARATION: "In Vorbereitung", DONE: "Abgeschlossen" };
|
||||
const STATUS_TONE: Record<AuditStatus, "mut" | "warn" | "ok"> = { PLANNED: "mut", IN_PREPARATION: "warn", DONE: "ok" };
|
||||
|
||||
/**
|
||||
* Audit-Übersicht (V5A) — Einstieg des Audit-Moduls (M4, Ebene „Audit vorbereiten").
|
||||
* Löst die frühere Wizard-Startseite (OnboardingProgress-Stepper) als primäre Ansicht
|
||||
* ab: Liste aller geplanten internen/externen Audits + Planungs-Popup. Externe Audits
|
||||
* öffnen die Vorbereitung (Wizard-Shell `/audit-readiness/[auditId]`); interne Audits
|
||||
* werden hier nur angelegt und terminiert (Status PLANNED) und liefern über ihre
|
||||
* Feststellungen den Rückfluss in die Readiness des externen Audits.
|
||||
*/
|
||||
export default async function AuditOverviewPage() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [audits, evidenceCounts, users] = await Promise.all([
|
||||
db.audit.findMany({ orderBy: [{ status: "asc" }, { createdAt: "desc" }] }),
|
||||
db.auditEvidenceItem.groupBy({ by: ["auditId", "status"], _count: { _all: true } }),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
|
||||
// Fortschritt externer Audits: Anteil bereitgestellter Nachweispositionen.
|
||||
const progressOf = (auditId: string): { done: number; total: number; percent: number } => {
|
||||
let done = 0;
|
||||
let total = 0;
|
||||
for (const g of evidenceCounts) {
|
||||
if (g.auditId !== auditId) continue;
|
||||
total += g._count._all;
|
||||
if (g.status === "bereitgestellt") done += g._count._all;
|
||||
}
|
||||
return { done, total, percent: total === 0 ? 0 : Math.round((done / total) * 100) };
|
||||
};
|
||||
|
||||
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? null;
|
||||
const toFormValues = (a: (typeof audits)[number]): AuditFormValues => ({
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
title: a.title,
|
||||
plannedDate: a.plannedDate,
|
||||
scope: a.scope,
|
||||
assessmentLevel: a.assessmentLevel,
|
||||
provider: a.provider,
|
||||
auditorUserId: a.auditorUserId,
|
||||
interval: a.result?.startsWith("Turnus: ") ? a.result.slice("Turnus: ".length) : null,
|
||||
});
|
||||
|
||||
const external = audits.filter((a) => a.type === "EXTERNAL");
|
||||
const internal = audits.filter((a) => a.type === "INTERNAL");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Audit"
|
||||
title="Audit-Vorbereitung"
|
||||
sub="Interne und externe Audits planen, terminieren und vorbereiten."
|
||||
actions={canUse ? <AuditDialog mode="create" action={createAudit} users={users} /> : undefined}
|
||||
/>
|
||||
|
||||
{audits.length === 0 ? (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-10 text-center">
|
||||
<ClipboardCheck className="mx-auto size-8 text-muted-foreground" />
|
||||
<p className="mt-3 text-[13px] font-medium">Noch keine Audits geplant</p>
|
||||
<p className="mt-1 text-[12.5px] text-muted-foreground">
|
||||
Legen Sie ein internes oder externes Audit an, um mit der Terminierung und Vorbereitung zu starten.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Externe Audits */}
|
||||
<section>
|
||||
<h2 className="mb-2 flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<FileSearch className="size-4" /> Externe Audits
|
||||
<span className="text-[12px] font-normal text-muted-foreground">({external.length})</span>
|
||||
</h2>
|
||||
{external.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine externen Audits geplant.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{external.map((a) => {
|
||||
const p = progressOf(a.id);
|
||||
return (
|
||||
<li key={a.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone="violet">{TYPE_LABEL[a.type]}</Pill>
|
||||
{a.assessmentLevel && <Pill tone="info">{a.assessmentLevel}</Pill>}
|
||||
<Pill tone={STATUS_TONE[a.status]}>{STATUS_LABEL[a.status]}</Pill>
|
||||
<span className="text-[13px] font-semibold">{a.title}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11.5px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><CalendarDays className="size-3.5" /> {a.plannedDate ?? "kein Termin"}</span>
|
||||
{a.provider && <span className="inline-flex items-center gap-1"><Users className="size-3.5" /> {a.provider}</span>}
|
||||
{a.scope && <span className="truncate">Scope: {a.scope}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] text-muted-foreground">Nachweise</p>
|
||||
<p className="text-[13px] font-semibold">{p.total === 0 ? "—" : `${p.done}/${p.total} · ${p.percent}%`}</p>
|
||||
</div>
|
||||
<Button size="sm" nativeButton={false} render={<Link href={`/audit-readiness/${a.id}`} />}>
|
||||
Vorbereitung öffnen
|
||||
</Button>
|
||||
{canUse && (
|
||||
<>
|
||||
<AuditDialog mode="edit" action={updateAudit.bind(null, a.id)} users={users} audit={toFormValues(a)} />
|
||||
<form action={deleteAudit.bind(null, a.id)}>
|
||||
<Button type="submit" variant="outline" size="sm">Löschen</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Interne Audits */}
|
||||
<section>
|
||||
<h2 className="mb-2 flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<ClipboardCheck className="size-4" /> Interne Audits
|
||||
<span className="text-[12px] font-normal text-muted-foreground">({internal.length})</span>
|
||||
</h2>
|
||||
{internal.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine internen Audits geplant.</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{internal.map((a) => (
|
||||
<li key={a.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone="mut">{TYPE_LABEL[a.type]}</Pill>
|
||||
<Pill tone={STATUS_TONE[a.status]}>{STATUS_LABEL[a.status]}</Pill>
|
||||
<span className="text-[13px] font-semibold">{a.title}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-[11.5px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1"><CalendarDays className="size-3.5" /> {a.plannedDate ?? "kein Termin"}</span>
|
||||
{a.auditorUserId && <span className="inline-flex items-center gap-1"><Users className="size-3.5" /> Auditor: {userName(a.auditorUserId) ?? "zugewiesen"}</span>}
|
||||
{a.result?.startsWith("Turnus: ") && <span>{a.result}</span>}
|
||||
{a.scope && <span className="truncate">Scope: {a.scope}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{canUse && (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<AuditDialog mode="edit" action={updateAudit.bind(null, a.id)} users={users} audit={toFormValues(a)} />
|
||||
<form action={deleteAudit.bind(null, a.id)}>
|
||||
<Button type="submit" variant="outline" size="sm">Löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canUse && <p className="mt-4 text-[12.5px] text-muted-foreground">Nur Lesezugriff — Audits planen erfordert das Recht „Onboarding nutzen“.</p>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import Link from "next/link";
|
||||
import { ListChecks, Zap } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { buildGapItems } from "@/server/gap-context";
|
||||
import { PRIORITY_LABEL, PRIORITY_TONE, type GapItem } from "@/lib/gap-consolidation";
|
||||
import { createConsolidatedGapTasks } from "@/server/actions/gap";
|
||||
|
||||
/**
|
||||
* Gap-Konsolidierung (Story A8-1/A8-2) — Schritt 2 des Audit-Wizards (M4, C8).
|
||||
* Führt die offenen Punkte aus dem Risiko-Register und dem Control-Assessment (laufend,
|
||||
* M3) zusammen — dedupliziert, priorisiert (C8 §1), Quick-Wins hervorgehoben (C8 §3) —
|
||||
* und gleicht sie mit dem laufenden Aufgaben-Board ab. Für Punkte ohne Aufgabe lässt
|
||||
* sich je eine (dedup-bewusste) Aufgabe anlegen.
|
||||
*/
|
||||
|
||||
const EFFORT_LABEL: Record<GapItem["effort"], string> = { gering: "gering", mittel: "mittel", hoch: "hoch" };
|
||||
|
||||
function GapRow({ item }: { item: GapItem }) {
|
||||
return (
|
||||
<li className="border-b py-2.5 last:border-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Pill tone={PRIORITY_TONE[item.priority]}>{PRIORITY_LABEL[item.priority]}</Pill>
|
||||
{item.quickWin && (
|
||||
<Pill tone="ok"><Zap className="mr-0.5 inline size-3" />Quick-Win</Pill>
|
||||
)}
|
||||
<span className="text-[12.5px] font-medium">{item.title}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{item.sources.includes("risk") && <Pill tone="violet">Risiko</Pill>}
|
||||
{item.affectedControls > 1 && <Pill tone="info">{item.affectedControls} Controls</Pill>}
|
||||
{item.taskId ? <Pill tone="mut">Aufgabe vorhanden</Pill> : <Pill tone="warn">ohne Aufgabe</Pill>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>{item.action}</span>
|
||||
<span>· Aufwand {EFFORT_LABEL[item.effort]}</span>
|
||||
{item.controls.length > 0 && <span>· Controls {item.controls.slice(0, 6).join(", ")}{item.controls.length > 6 ? " …" : ""}</span>}
|
||||
{item.riskScore != null && <span>· Risikowert {item.riskScore}</span>}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export async function GapStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const { items, summary } = await buildGapItems(db, session.user.tenantId);
|
||||
const quickWins = items.filter((i) => i.quickWin);
|
||||
|
||||
const kpis = [
|
||||
{ label: "offene Punkte", value: summary.total },
|
||||
{ label: "Hoch", value: summary.hoch },
|
||||
{ label: "Quick-Wins", value: summary.quickWins },
|
||||
{ label: "ohne Aufgabe", value: summary.withoutTask },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-heading text-sm font-semibold"><ListChecks className="size-4" /> Gap-Konsolidierung</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/tasks" />}>Aufgaben</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Offene Punkte aus Risiko-Register und Control-Assessment (laufend), zusammengeführt und
|
||||
dedupliziert (eine Maßnahme, alle Bezüge). Priorität deterministisch nach C8 §1; Sortierung nach Priorität,
|
||||
betroffenen Controls, Risikohöhe und Aufwand. Empfehlung: erst Quick-Wins, dann Hoch-Aufwand.
|
||||
</p>
|
||||
{canUse && (
|
||||
<form action={createConsolidatedGapTasks} className="mt-3">
|
||||
<Button type="submit" size="sm" variant="outline" disabled={summary.withoutTask === 0}>
|
||||
Aufgaben anlegen{summary.withoutTask ? ` (${summary.withoutTask})` : ""}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{quickWins.length > 0 && (
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--ok)] bg-[var(--surface-soft)] p-4">
|
||||
<span className="flex items-center gap-2 font-heading text-sm font-semibold"><Zap className="size-4" /> Quick-Wins ({quickWins.length}) — zuerst erledigen</span>
|
||||
<ul className="mt-2">
|
||||
{quickWins.map((i) => <GapRow key={i.id} item={i} />)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Konsolidierter Maßnahmenplan (priorisiert)</span>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Keine offenen Punkte — alle Anforderungen auf Zielreifegrad, keine Risiken oberhalb der Akzeptanzlinie.</p>
|
||||
) : (
|
||||
<ul className="mt-2">
|
||||
{items.map((i) => <GapRow key={i.id} item={i} />)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, CheckCircle2, Download, FileText } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildControlRows } from "@/server/soa-context";
|
||||
import { computeReadiness, type ControlAssessment } from "@/lib/readiness";
|
||||
|
||||
const PZ_LABEL: Record<string, string> = {
|
||||
informationssicherheit: "Informationssicherheit",
|
||||
prototypenschutz: "Prototypenschutz",
|
||||
datenschutz: "Datenschutz",
|
||||
};
|
||||
|
||||
/** Kapitel-/Control-Präfix → Prüfziel (8.x Prototyp, 9.x Datenschutz, sonst IS). */
|
||||
const chapterToPruefziel = (control: string): string =>
|
||||
control.startsWith("8.") ? "prototypenschutz" : control.startsWith("9.") ? "datenschutz" : "informationssicherheit";
|
||||
|
||||
/**
|
||||
* Readiness-Report + Management-Review (Story B7-1) — Schritt 4 des Audit-Wizards (M4).
|
||||
* Reifegrad-Dashboard + Interpretation (C9 §1) und dynamische nächste Schritte (C9 §2).
|
||||
* Die Control-Reifegrad-Aggregation speist sich kontinuierlich aus dem Control-Assessment
|
||||
* (M3) — hier erfolgt nur die Konsolidierung, keine neue Reifegrad-Erhebung. Der Export
|
||||
* (VDA-ISA-Katalog) und die Management-Zusammenfassung dienen dem Management-Review.
|
||||
*/
|
||||
export async function ReadinessStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [scope, reqs, openTasks, progress, controlRows] = await Promise.all([
|
||||
db.wizardScope.findFirst(),
|
||||
db.policyRequirement.findMany({ where: { archivedAt: null }, select: { control: true } }),
|
||||
db.task.findMany({ where: { status: "OPEN" }, select: { priority: true, type: true } }),
|
||||
db.onboardingProgress.findMany({ select: { status: true } }),
|
||||
buildControlRows(db, session.user.tenantId).then((x) => x.rows),
|
||||
]);
|
||||
const pruefziele = scope?.pruefziele ?? ["informationssicherheit"];
|
||||
|
||||
const covByPz = new Map<string, number>();
|
||||
for (const r of reqs) {
|
||||
const pz = chapterToPruefziel(r.control);
|
||||
if (!pruefziele.includes(pz)) continue;
|
||||
covByPz.set(pz, (covByPz.get(pz) ?? 0) + 1);
|
||||
}
|
||||
const pruefzielAbdeckung = pruefziele.map((pz) => ({ pruefziel: pz, anforderungen: covByPz.get(pz) ?? 0 }));
|
||||
|
||||
const offenJePrioritaet = {
|
||||
hoch: openTasks.filter((t) => t.priority === "hoch").length,
|
||||
mittel: openTasks.filter((t) => t.priority === "mittel").length,
|
||||
niedrig: openTasks.filter((t) => t.priority === "niedrig").length,
|
||||
};
|
||||
|
||||
// Control-Assessments aus Schritt 7 (Story A7): bestätigter Reifegrad je Control,
|
||||
// „unbestätigt zählt nicht als erfüllt" (bestaetigt=false → 0 im Ø).
|
||||
const assessments: ControlAssessment[] = controlRows.map((row) => ({
|
||||
control: row.control,
|
||||
chapter: row.control.split(".")[0],
|
||||
reifegrad: row.confirmed ?? 0,
|
||||
bestaetigt: row.confirmed !== null,
|
||||
}));
|
||||
const bestaetigtAnteil = controlRows.length === 0 ? 0 : controlRows.filter((row) => row.confirmed !== null).length / controlRows.length;
|
||||
|
||||
const r = computeReadiness({
|
||||
assessments,
|
||||
bestaetigtAnteil,
|
||||
offenJePrioritaet,
|
||||
pruefzielAbdeckung,
|
||||
zielReifegrad: 3,
|
||||
nextSteps: {
|
||||
openHighCount: offenJePrioritaet.hoch,
|
||||
unvalidatedCount: progress.filter((p) => p.status === "zur_validierung").length,
|
||||
prototypeGap: pruefziele.includes("prototypenschutz"),
|
||||
evidenceUploadPending: openTasks.some((t) => t.type === "evidence_provide"),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Reifegrad + Interpretation (C9 §1) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-heading text-sm font-semibold">Reifegrad & Assessment-Reife</p>
|
||||
{r.band ? <Pill tone={r.band.tone}>{r.band.band} · Ø {r.avgReifegrad?.toFixed(1)}</Pill> : <Pill tone="mut">Reifegrad ausstehend</Pill>}
|
||||
</div>
|
||||
{r.assessmentPending ? (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-[12.5px] text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-warn" />
|
||||
Die Reifegrad-Aggregation aktiviert sich, sobald das Control-Assessment (Schritt 7) vorliegt. Zielreifegrad: {r.zielReifegrad}.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">{r.band?.text}</p>
|
||||
)}
|
||||
{r.byChapter.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{r.byChapter.map((c) => <Pill key={c.chapter} tone="info">Kap. {c.chapter}: Ø {c.avg.toFixed(1)}</Pill>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kennzahlen */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">Offene Punkte je Priorität</p>
|
||||
<div className="flex gap-2 text-[12.5px]">
|
||||
<Pill tone="risk">Hoch {r.offenJePrioritaet.hoch}</Pill>
|
||||
<Pill tone="warn">Mittel {r.offenJePrioritaet.mittel}</Pill>
|
||||
<Pill tone="mut">Niedrig {r.offenJePrioritaet.niedrig}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">Abdeckung je Prüfziel</p>
|
||||
<ul className="space-y-1 text-[12.5px]">
|
||||
{r.pruefzielAbdeckung.map((p) => (
|
||||
<li key={p.pruefziel} className="flex justify-between gap-2">
|
||||
<span>{PZ_LABEL[p.pruefziel] ?? p.pruefziel}</span>
|
||||
<span className="text-muted-foreground">{p.anforderungen} Anforderungen</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nächste Schritte (C9 §2) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">Empfohlene nächste Schritte</p>
|
||||
{r.nextSteps.length === 0 ? (
|
||||
<p className="flex items-center gap-1.5 text-[12.5px] text-muted-foreground"><CheckCircle2 className="size-4 text-[var(--ok)]" /> Keine offenen Punkte — bereit für die Stichprobenvalidierung.</p>
|
||||
) : (
|
||||
<ul className="list-disc space-y-1 pl-5 text-[12.5px]">
|
||||
{r.nextSteps.map((s, i) => <li key={i}>{s}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Export (Story B7-2, C9 §3/§4) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-1 text-[13px] font-semibold">Export</p>
|
||||
<p className="mb-2 text-[11.5px] text-muted-foreground">VDA-ISA-Katalogsicht (Reihenfolge IS→Prototyp→Datenschutz, unbestätigte Controls markiert) und Management-Zusammenfassung.</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- CSV-Download-Route-Handler, kein Client-Navigations-Ziel */}
|
||||
<Button size="sm" variant="outline" nativeButton={false} render={<a href="/audit-readiness/export" />}><Download className="size-3.5" /> VDA-ISA-Katalog (CSV)</Button>
|
||||
<Button size="sm" variant="outline" nativeButton={false} render={<Link href="/audit-readiness/summary" />}><FileText className="size-3.5" /> Management-Zusammenfassung</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Link from "next/link";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { buildExportRows } from "@/server/export-context";
|
||||
import { buildGapItems } from "@/server/gap-context";
|
||||
import { kennzahlen, PRUEFZIEL_LABEL } from "@/lib/export/vda-isa";
|
||||
import { interpretationBand, buildNextSteps } from "@/lib/readiness";
|
||||
import { PRIORITY_LABEL, PRIORITY_TONE } from "@/lib/gap-consolidation";
|
||||
|
||||
/**
|
||||
* Management-Zusammenfassung (Story B7-2, C9 §4): Reifegrad-Überblick, Stärken,
|
||||
* Hoch-Punkte, empfohlene Schritte und priorisierter Maßnahmenplan (Quick-Wins
|
||||
* hervorgehoben). Druckbar (Browser → „Als PDF speichern"). Datenquellen: A7/A8.
|
||||
*/
|
||||
export default async function ManagementSummaryPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "onboarding:use");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [rows, gap] = await Promise.all([
|
||||
buildExportRows(db, session.user.tenantId),
|
||||
buildGapItems(db, session.user.tenantId),
|
||||
]);
|
||||
const k = kennzahlen(rows);
|
||||
const band = k.gesamtAvg === null ? null : interpretationBand(k.gesamtAvg);
|
||||
const staerken = rows.filter((r) => r.bestaetigt && (r.reifegrad ?? 0) >= 2).length;
|
||||
const quickWins = gap.items.filter((g) => g.quickWin);
|
||||
|
||||
const nextSteps = buildNextSteps({
|
||||
openHighCount: gap.summary.hoch,
|
||||
unvalidatedCount: rows.filter((r) => !r.bestaetigt).length,
|
||||
prototypeGap: rows.some((r) => r.pruefziel === "prototypenschutz"),
|
||||
evidenceUploadPending: gap.summary.withoutTask > 0,
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Audit · Readiness"
|
||||
title="Management-Zusammenfassung"
|
||||
sub="Assessment-Readiness auf einen Blick — über den Browser als PDF druckbar."
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/audit-readiness?step=audit_readiness" />}>Zur Readiness</Button>
|
||||
{/* eslint-disable-next-line @next/next/no-html-link-for-pages -- CSV-Download-Route-Handler, kein Client-Navigations-Ziel */}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<a href="/audit-readiness/export" />}>VDA-ISA-Katalog (CSV)</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Reifegrad-Überblick */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-heading text-sm font-semibold">Reifegrad-Überblick</h2>
|
||||
{band ? <Pill tone={band.tone}>{band.band} · Ø {k.gesamtAvg!.toFixed(1)} / Ziel 3</Pill> : <Pill tone="mut">Reifegrad ausstehend</Pill>}
|
||||
</div>
|
||||
{band && <p className="mt-2 text-[12.5px] text-muted-foreground">{band.text}</p>}
|
||||
<div className="mt-3 grid gap-2 text-[12.5px] sm:grid-cols-3">
|
||||
<div><span className="text-muted-foreground">Bestätigte Controls: </span>{k.bestaetigt}/{k.total} ({Math.round(k.bestaetigtAnteil * 100)} %)</div>
|
||||
<div><span className="text-muted-foreground">Stärken (Ø ≥ 2, bestätigt): </span>{staerken}</div>
|
||||
<div><span className="text-muted-foreground">Offene Punkte: </span>{gap.summary.total} ({gap.summary.hoch} hoch)</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{k.jePruefziel.map((p) => (
|
||||
<Pill key={p.pruefziel} tone="info">{PRUEFZIEL_LABEL[p.pruefziel]}: Ø {p.avg?.toFixed(1) ?? "—"} ({p.bestaetigt}/{p.controls})</Pill>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Empfohlene nächste Schritte */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<h2 className="mb-2 font-heading text-sm font-semibold">Empfohlene Schritte vor dem Assessment</h2>
|
||||
{nextSteps.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine offenen Punkte — bereit für die Stichprobenvalidierung.</p>
|
||||
) : (
|
||||
<ul className="list-disc space-y-1 pl-5 text-[12.5px]">{nextSteps.map((s, i) => <li key={i}>{s}</li>)}</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Maßnahmenplan (C9 §4) */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<h2 className="mb-2 font-heading text-sm font-semibold">Maßnahmenplan {quickWins.length > 0 && <span className="text-[12px] font-normal text-muted-foreground">· {quickWins.length} Quick-Wins</span>}</h2>
|
||||
{gap.items.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Keine offenen Maßnahmen.</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5">
|
||||
{gap.items.slice(0, 30).map((g) => (
|
||||
<li key={g.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<Pill tone={PRIORITY_TONE[g.priority]}>{PRIORITY_LABEL[g.priority]}</Pill>
|
||||
{g.quickWin && <Pill tone="ok">Quick-Win</Pill>}
|
||||
<span className="font-medium">{g.title}</span>
|
||||
<span className="text-muted-foreground">→ {g.action}{g.controls.length ? ` (${g.controls.join(", ")})` : ""}</span>
|
||||
{g.taskId && <span className="text-[11px] text-[var(--ok)]">· Aufgabe vorhanden</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{gap.items.length > 30 && <p className="mt-2 text-[11.5px] text-muted-foreground">… und {gap.items.length - 30} weitere. Vollständig im Gap-Schritt (8) und im CSV-Export.</p>}
|
||||
</section>
|
||||
|
||||
<p className="text-[11.5px] text-muted-foreground">Nachweisregister und vollständige Katalogsicht siehe VDA-ISA-Katalog-Export (CSV). Zum PDF: über den Browser drucken und als PDF speichern.</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Compass, ListChecks } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { MODULE_KEYS } from "@/lib/modules";
|
||||
import { openReportDeadlines } from "@/lib/incident-deadlines";
|
||||
import { getVisibleSteps, type StepKey } from "@/lib/onboarding/registry";
|
||||
import { firstIncompleteIndex, isValidated, progressPercent } from "@/lib/onboarding/state";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("dashboard");
|
||||
const format = await getFormatter();
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const [assetCount, processCount, criticalCount, openRisks, myOpenTasks, activities, moduleRows, progressRows] = await Promise.all([
|
||||
db.asset.count(),
|
||||
db.process.count(),
|
||||
db.biaEntry.count({ where: { criticality: { gte: 3 } } }),
|
||||
db.risk.count({ where: { status: { in: ["OPEN", "IN_TREATMENT"] } } }),
|
||||
db.task.count({ where: { assigneeId: session.user.id, status: "OPEN" } }),
|
||||
db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 6,
|
||||
}),
|
||||
db.tenantModule.findMany(),
|
||||
db.onboardingProgress.findMany(),
|
||||
]);
|
||||
|
||||
// Onboarding-Fortschritt (Story A1-2): Kachel nur, wenn Modul aktiv und noch nicht fertig.
|
||||
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const to = await getTranslations("onboarding");
|
||||
let onboardingTile: { percent: number; done: number; total: number; nextKey: StepKey; nextTitle: string } | null = null;
|
||||
if (!disabledModules.has("onboarding")) {
|
||||
const steps = getVisibleSteps({ enabledModules: new Set(MODULE_KEYS.filter((k) => !disabledModules.has(k))) });
|
||||
const statusOf = (k: StepKey) => progressRows.find((r) => r.stepKey === k)?.status ?? "offen";
|
||||
const percent = progressPercent(steps, statusOf);
|
||||
if (steps.length > 0 && percent < 100) {
|
||||
const next = steps[firstIncompleteIndex(steps, statusOf)];
|
||||
onboardingTile = {
|
||||
percent,
|
||||
done: steps.filter((s) => isValidated(statusOf(s.key))).length,
|
||||
total: steps.length,
|
||||
nextKey: next.key,
|
||||
nextTitle: to(next.title),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Vorfälle/Fristen-Kachel (IM-B, §6): offene meldepflichtige Fristen + überfällige.
|
||||
// Nur wenn Modul „incidents" aktiv und der Nutzer Vorfälle sehen darf.
|
||||
let incidentTile: { open: number; overdue: number } | null = null;
|
||||
if (!disabledModules.has("incidents") && hasPermission(session, "incident:read")) {
|
||||
const incs = await db.incident.findMany({
|
||||
where: {
|
||||
status: { not: "abgeschlossen" },
|
||||
reportStatus: { not: "abschluss" },
|
||||
OR: [
|
||||
{ erstmeldungDueAt: { not: null } },
|
||||
{ folgemeldungDueAt: { not: null } },
|
||||
{ abschlussDueAt: { not: null } },
|
||||
{ dsgvoDueAt: { not: null } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
severity: true,
|
||||
reportStatus: true,
|
||||
detectedAt: true,
|
||||
reportedAt: true,
|
||||
occurredAt: true,
|
||||
createdAt: true,
|
||||
erstmeldungDueAt: true,
|
||||
folgemeldungDueAt: true,
|
||||
abschlussDueAt: true,
|
||||
dsgvoDueAt: true,
|
||||
},
|
||||
take: 500,
|
||||
});
|
||||
const now = new Date();
|
||||
let open = 0;
|
||||
let overdue = 0;
|
||||
for (const inc of incs) {
|
||||
const items = openReportDeadlines(inc, now);
|
||||
if (items.length === 0) continue;
|
||||
open++;
|
||||
if (items.some((i) => i.overdue)) overdue++;
|
||||
}
|
||||
if (open > 0) incidentTile = { open, overdue };
|
||||
}
|
||||
|
||||
const actorNames = new Map<string, string>();
|
||||
const actorIds = [...new Set(activities.map((a) => a.actorId).filter(Boolean))] as string[];
|
||||
if (actorIds.length) {
|
||||
const users = await db.user.findMany({
|
||||
where: { id: { in: actorIds } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
users.forEach((u) => actorNames.set(u.id, u.name));
|
||||
}
|
||||
|
||||
const kpis = [
|
||||
{ label: t("kpiAssets"), value: String(assetCount), hint: null },
|
||||
{ label: t("kpiProcesses"), value: String(processCount), hint: null },
|
||||
{ label: t("kpiCritical"), value: String(criticalCount), hint: t("kpiCriticalHint") },
|
||||
{ label: t("kpiRisks"), value: String(openRisks), hint: null },
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<h1 className="text-xl">{t("title")}</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug })}
|
||||
</p>
|
||||
|
||||
{onboardingTile && (
|
||||
<Link href={`/onboarding?step=${onboardingTile.nextKey}`} className="shadow-card mt-6 flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-lg bg-[rgba(124,92,209,0.16)] text-[var(--primary)]"><Compass className="size-5" /></div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{t("onboardingTile", { percent: onboardingTile.percent })}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t("onboardingTileSub", { done: onboardingTile.done, total: onboardingTile.total, step: onboardingTile.nextTitle })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">{t("onboardingTileCta")}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{myOpenTasks > 0 && (
|
||||
<Link href="/tasks" className={`shadow-card ${onboardingTile ? "mt-4" : "mt-6"} flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="grid size-10 place-items-center rounded-lg bg-[rgba(90,169,230,0.16)] text-[var(--info)]"><ListChecks className="size-5" /></div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{myOpenTasks} offene Aufgabe{myOpenTasks === 1 ? "" : "n"}</p>
|
||||
<p className="text-[12px] text-muted-foreground">Freigaben, die auf Sie warten.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">Zu den Aufgaben →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{incidentTile && (
|
||||
<Link href="/incidents" className="shadow-card mt-4 flex items-center justify-between rounded-xl border bg-card p-4 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`grid size-10 place-items-center rounded-lg ${incidentTile.overdue > 0 ? "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]" : "bg-[rgba(240,173,78,0.16)] text-[var(--warn)]"}`}>
|
||||
<AlertTriangle className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{t("incidentTile")}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t("incidentTileSub", { open: incidentTile.open, overdue: incidentTile.overdue })}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">{t("incidentTileCta")}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{kpis.map((kpi) => (
|
||||
<Card key={kpi.label}>
|
||||
<CardContent>
|
||||
<p className="text-[12.5px] font-semibold text-muted-foreground">{kpi.label}</p>
|
||||
<p className="mt-1.5 font-heading text-3xl font-bold leading-none">{kpi.value}</p>
|
||||
{kpi.hint && <p className="mt-1.5 text-xs text-muted-foreground">{kpi.hint}</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="mt-6 max-w-3xl">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("activity")}</CardTitle>
|
||||
<CardDescription>{t("activitySub")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{activities.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("activityEmpty")}</p>
|
||||
)}
|
||||
<ul className="space-y-2 text-sm">
|
||||
{activities.map((a) => (
|
||||
<li key={a.id} className="flex items-baseline gap-3">
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{format.relativeTime(a.createdAt)}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="font-semibold">
|
||||
{a.actorId ? actorNames.get(a.actorId) ?? "System" : "System"}
|
||||
</strong>{" "}
|
||||
· {a.action} · {a.entity}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("dependencies");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { buildDependencyGraph } from "@/server/dependency-graph";
|
||||
import { PageHead, SectTitle } from "@/components/mockup-ui";
|
||||
import { DependencyGraphView } from "@/components/dependency-graph";
|
||||
|
||||
export default async function DependenciesPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
const t = await getTranslations("dependencies");
|
||||
const tType = await getTranslations("assetType");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const graph = await buildDependencyGraph(db);
|
||||
|
||||
const kindLabels: Record<string, string> = {
|
||||
process: "Prozess",
|
||||
INFORMATION: tType("INFORMATION"),
|
||||
SYSTEM: tType("SYSTEM"),
|
||||
APPLICATION: tType("APPLICATION"),
|
||||
LOCATION: tType("LOCATION"),
|
||||
SUPPLIER: tType("SUPPLIER"),
|
||||
PERSON: tType("PERSON"),
|
||||
DATA: tType("DATA"),
|
||||
};
|
||||
const labels = {
|
||||
search: t("search"),
|
||||
criticalToggle: t("criticalToggle"),
|
||||
onlyProcesses: t("onlyProcesses"),
|
||||
fit: t("fit"),
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
{graph.nodes.length === 0 ? (
|
||||
<div className="shadow-card rounded-xl border bg-card p-10 text-center text-sm text-muted-foreground">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 xl:grid-cols-[1fr_18rem]">
|
||||
<DependencyGraphView graph={graph} kindLabels={kindLabels} labels={labels} />
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Kennzahlen */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("critProcesses")}
|
||||
</div>
|
||||
<div className="mt-1 font-heading text-2xl font-bold text-[#ff6b6b]">
|
||||
{graph.analysis.criticalProcessCount}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("critEdges")}
|
||||
</div>
|
||||
<div className="mt-1 font-heading text-2xl font-bold">
|
||||
{graph.analysis.criticalEdgeCount}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SPOF */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<SectTitle title={t("spofTitle")} />
|
||||
{graph.analysis.spofs.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("spofNone")}</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-2 text-sm">
|
||||
{graph.analysis.spofs.slice(0, 6).map((s) => (
|
||||
<li key={s.id} className="rounded-lg border border-[rgba(255,107,107,0.3)] bg-[rgba(255,107,107,0.08)] p-2.5">
|
||||
<div className="font-semibold text-[#ff6b6b]">{s.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("spofHint", { count: s.count })}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Kritischster Pfad */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<SectTitle title={t("critPathTitle")} />
|
||||
{graph.analysis.longestCriticalPath.length === 0 ? (
|
||||
<p className="mt-2 text-sm text-muted-foreground">{t("critPathNone")}</p>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1 text-[12.5px]">
|
||||
{graph.analysis.longestCriticalPath.map((n, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
{i > 0 && <span className="text-[#ff6b6b]">→</span>}
|
||||
<span className="rounded-md bg-[rgba(255,107,107,0.12)] px-2 py-0.5 font-medium text-[#ffd0d0]">
|
||||
{n}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Legende */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4 text-[12.5px]">
|
||||
<SectTitle title={t("legend")} />
|
||||
<ul className="mt-2 space-y-1.5 text-muted-foreground">
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-[3px] w-6 rounded bg-[#ff6b6b]" /> {t("legCritical")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="h-[2px] w-6 rounded bg-[#4a5372]" /> {t("legStandard")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="rounded bg-[rgba(255,107,107,0.18)] px-1.5 py-px text-[9.5px] font-bold text-[#ff6b6b]">
|
||||
SPOF
|
||||
</span>{" "}
|
||||
{t("legSpof")}
|
||||
</li>
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="inline-block size-2.5 rounded-full bg-[#ff6b6b]" /> {t("legCrit")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
|
||||
/**
|
||||
* Download-Route für hochgeladene Nachweise/Richtlinien (Epic S1). Liefert die im
|
||||
* Objektspeicher (MinIO/S3) abgelegten Bytes anhand ihres Storage-Keys aus.
|
||||
*
|
||||
* Mandanten-Isolation (mehrschichtig):
|
||||
* 1. Der Key ist mandantenpräfixiert (`<tenantId>/uploads/…`). Er MUSS mit dem
|
||||
* Tenant der aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404
|
||||
* abgewiesen (keine Existenz-Preisgabe).
|
||||
* 2. Zusätzlich (Defense in Depth) muss der Key in einer mandantengebundenen
|
||||
* Referenz vorkommen (`Evidence.fileRef` oder `PolicyRequirement.nachweisLink`);
|
||||
* so werden nur tatsächlich referenzierte Objekte ausgeliefert, keine geratenen.
|
||||
*
|
||||
* Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options:
|
||||
* nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing.
|
||||
*
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig
|
||||
* über `requireSession` erzwungen (Autorisierung = Mandantenbindung des Keys).
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ key: string[] }> },
|
||||
) {
|
||||
const session = await requireSession();
|
||||
const tenantId = session.user.tenantId;
|
||||
|
||||
const { key: segments } = await params;
|
||||
// Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen.
|
||||
const key = (segments ?? []).join("/");
|
||||
|
||||
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
|
||||
if (
|
||||
!key ||
|
||||
key.includes("..") ||
|
||||
key.includes("\0") ||
|
||||
!key.startsWith(`${tenantId}/`)
|
||||
) {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
// Defense in Depth: Key muss in einer mandantengebundenen Referenz vorkommen.
|
||||
const db = dbForTenant(tenantId);
|
||||
const [evidence, requirement] = await Promise.all([
|
||||
db.evidence.findFirst({ where: { fileRef: key }, select: { id: true } }),
|
||||
db.policyRequirement.findFirst({ where: { nachweisLink: key }, select: { reqId: true } }),
|
||||
]);
|
||||
if (!evidence && !requirement) {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
const content = await storage.get(key);
|
||||
if (!content) {
|
||||
// Kein Byte-Backend (Stub) oder Objekt fehlt → 404.
|
||||
return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
}
|
||||
|
||||
const filename = content.filename.replace(/["\\]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": content.contentType ?? "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
|
||||
return new Response(content.stream, { headers });
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { assertModuleEnabled } from "@/server/modules";
|
||||
import { canSeeRestricted } from "@/lib/incident";
|
||||
import { toRegisterCsv, buildNis2Template, buildDsgvoTemplate } from "@/lib/incident-export";
|
||||
import { renderIncidentReportHtml } from "@/lib/incident-report-html";
|
||||
import { buildIncidentRegisterXlsx } from "@/server/export/incident-xlsx";
|
||||
import {
|
||||
buildIncidentRegisterInputs,
|
||||
buildIncidentReportData,
|
||||
buildIncidentTemplateInput,
|
||||
} from "@/server/incident-export-context";
|
||||
|
||||
/**
|
||||
* Export/Nachweise des Moduls „Vorfälle" (IM-C, §9/§72). Sichten über `?format`:
|
||||
* - `register-csv` (Default) / `register-xlsx`: Vorfallregister (Liste).
|
||||
* - `report&id=<id>`: gebrandeter, druckbarer Einzel-Vorfallbericht (HTML,
|
||||
* inline → im Browser drucken / als PDF speichern).
|
||||
* - `nis2&id=<id>` / `dsgvo&id=<id>`: vorbefüllte Meldevorlage (.txt-Download)
|
||||
* für die MANUELLE Übermittlung an die Behörde (keine Behörden-API).
|
||||
*
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate → Modul-Aktivierung + Recht
|
||||
* werden hier eigenständig geprüft. Vertraulichkeit (§11) wird über denselben
|
||||
* serverseitigen Filter wie die Liste durchgesetzt.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "incident:read")) {
|
||||
return new Response("Nicht berechtigt.", { status: 403 });
|
||||
}
|
||||
try {
|
||||
await assertModuleEnabled(session, "incidents");
|
||||
} catch {
|
||||
return new Response("Modul Vorfaelle ist nicht aktiviert.", { status: 404 });
|
||||
}
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const url = new URL(req.url);
|
||||
const format = url.searchParams.get("format") ?? "register-csv";
|
||||
const id = url.searchParams.get("id");
|
||||
|
||||
// §11 Vertraulichkeit: wer nicht manage/close hat, sieht nur unrestricted + eigene.
|
||||
const restrictedWhere: Prisma.IncidentWhereInput = canSeeRestricted(session)
|
||||
? {}
|
||||
: { OR: [{ restricted: false }, { ownerId: session.user.id }] };
|
||||
|
||||
// ── Register (Liste) ──────────────────────────────────────────────────────
|
||||
if (format === "register-csv" || format === "register-xlsx") {
|
||||
const rows = await buildIncidentRegisterInputs(db, restrictedWhere);
|
||||
if (format === "register-xlsx") {
|
||||
const buffer = await buildIncidentRegisterXlsx(rows);
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="vorfallregister.xlsx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(toRegisterCsv(rows), {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="vorfallregister.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Ab hier ist ein konkreter Vorfall nötig.
|
||||
if (!id) return new Response("Parameter id fehlt.", { status: 400 });
|
||||
const where: Prisma.IncidentWhereInput = { AND: [{ id }, restrictedWhere] };
|
||||
|
||||
// ── Einzel-Vorfallbericht (druckbares HTML) ───────────────────────────────
|
||||
if (format === "report") {
|
||||
const data = await buildIncidentReportData(db, session.user.tenantId, where);
|
||||
if (!data) return new Response("Vorfall nicht gefunden.", { status: 404 });
|
||||
return new Response(renderIncidentReportHtml(data), {
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── Meldevorlagen (.txt-Download) ─────────────────────────────────────────
|
||||
if (format === "nis2" || format === "dsgvo") {
|
||||
const input = await buildIncidentTemplateInput(db, session.user.tenantId, where);
|
||||
if (!input) return new Response("Vorfall nicht gefunden.", { status: 404 });
|
||||
const text = format === "nis2" ? buildNis2Template(input) : buildDsgvoTemplate(input);
|
||||
const filename = `${input.refNo}-${format === "nis2" ? "NIS2" : "DSGVO"}-Meldevorlage.txt`;
|
||||
return new Response(text, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response("Unbekanntes Format.", { status: 400 });
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("incidents");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
INCIDENT_INCLUDE,
|
||||
IncidentCreateModal,
|
||||
IncidentDetailModal,
|
||||
IncidentEditModal,
|
||||
} from "@/components/incident-modals";
|
||||
import {
|
||||
INCIDENT_CATEGORIES,
|
||||
INCIDENT_STATUSES,
|
||||
SEVERITY_TONE,
|
||||
STATUS_TONE,
|
||||
canSeeRestricted,
|
||||
type IncidentStatus,
|
||||
} from "@/lib/incident";
|
||||
import { INCIDENT_SEVERITIES, type IncidentSeverity } from "@/lib/incident-severity";
|
||||
|
||||
export default async function IncidentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
detail?: string;
|
||||
edit?: string;
|
||||
new?: string;
|
||||
status?: string;
|
||||
severity?: string;
|
||||
category?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "incident:read");
|
||||
const t = await getTranslations("incidents");
|
||||
const tCat = await getTranslations("incidentCategory");
|
||||
const tStatus = await getTranslations("incidentStatus");
|
||||
const tSev = await getTranslations("incidentSeverity");
|
||||
|
||||
const params = await searchParams;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canManage = hasPermission(session, "incident:manage");
|
||||
const canClose = hasPermission(session, "incident:close");
|
||||
const canReport = hasPermission(session, "incident:report");
|
||||
|
||||
// §11 Vertraulichkeit: wer nicht manage/close hat, sieht nur unrestricted +
|
||||
// seine eigenen (owner) Vorfälle — serverseitiger Filter.
|
||||
const restrictedWhere: Prisma.IncidentWhereInput = canSeeRestricted(session)
|
||||
? {}
|
||||
: { OR: [{ restricted: false }, { ownerId: session.user.id }] };
|
||||
|
||||
const filterWhere: Prisma.IncidentWhereInput = {
|
||||
...(params.status && INCIDENT_STATUSES.includes(params.status as IncidentStatus)
|
||||
? { status: params.status as IncidentStatus }
|
||||
: {}),
|
||||
...(params.severity && INCIDENT_SEVERITIES.includes(params.severity as IncidentSeverity)
|
||||
? { severity: params.severity }
|
||||
: {}),
|
||||
...(params.category &&
|
||||
INCIDENT_CATEGORIES.includes(params.category as (typeof INCIDENT_CATEGORIES)[number])
|
||||
? { category: params.category as (typeof INCIDENT_CATEGORIES)[number] }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const incidents = await db.incident.findMany({
|
||||
where: { AND: [restrictedWhere, filterWhere] },
|
||||
include: { owner: { select: { name: true } } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
// Detail/Bearbeiten-Popup
|
||||
const modalId = params.edit && canManage ? params.edit : params.detail;
|
||||
const modalIncident = modalId
|
||||
? await db.incident.findFirst({
|
||||
where: { AND: [{ id: modalId }, restrictedWhere] },
|
||||
include: INCIDENT_INCLUDE,
|
||||
})
|
||||
: null;
|
||||
|
||||
// Für die Detail-Timeline: Audit-Einträge dieses Vorfalls + Akteursnamen.
|
||||
let auditRows: {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
after: unknown;
|
||||
}[] = [];
|
||||
let actorNames: Record<string, string> = {};
|
||||
if (modalIncident && params.detail) {
|
||||
const rows = await db.auditLog.findMany({
|
||||
where: { entity: "incident", entityId: modalIncident.id },
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
auditRows = rows.map((r) => ({
|
||||
id: r.id,
|
||||
createdAt: r.createdAt,
|
||||
actorId: r.actorId,
|
||||
action: r.action,
|
||||
after: r.after,
|
||||
}));
|
||||
const actorIds = new Set<string>();
|
||||
rows.forEach((r) => r.actorId && actorIds.add(r.actorId));
|
||||
modalIncident.comments.forEach((c) => c.authorId && actorIds.add(c.authorId));
|
||||
const actors = await db.user.findMany({
|
||||
where: { id: { in: [...actorIds] } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
actorNames = Object.fromEntries(actors.map((a) => [a.id, a.name]));
|
||||
}
|
||||
|
||||
// Für das Bearbeiten-Popup: Auswahl-Listen (nur noch nicht verknüpfte).
|
||||
const needsEdit = canManage && params.edit && modalIncident;
|
||||
const [availableAssets, availableProcesses, availableRisks, availableMeasures, availableEvidence] = needsEdit
|
||||
? await Promise.all([
|
||||
db.asset.findMany({
|
||||
where: { id: { notIn: modalIncident.incidentAssets.map((x) => x.assetId) } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.process.findMany({
|
||||
where: { id: { notIn: modalIncident.incidentProcesses.map((x) => x.processId) } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.risk.findMany({
|
||||
where: { id: { notIn: modalIncident.incidentRisks.map((x) => x.riskId) } },
|
||||
select: { id: true, refNo: true, title: true },
|
||||
orderBy: { refNo: "asc" },
|
||||
}),
|
||||
db.measure.findMany({
|
||||
where: { id: { notIn: modalIncident.incidentMeasures.map((x) => x.measureId) } },
|
||||
select: { id: true, refNo: true, title: true },
|
||||
orderBy: { refNo: "asc" },
|
||||
}),
|
||||
db.evidence.findMany({
|
||||
where: { id: { notIn: modalIncident.incidentEvidence.map((x) => x.evidenceId) } },
|
||||
select: { id: true, title: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
}),
|
||||
])
|
||||
: [[], [], [], [], []];
|
||||
|
||||
// Aktive Nutzer für Owner-/Bearbeiter-/Maßnahmen-Auswahl (Detail + Bearbeiten).
|
||||
const users =
|
||||
modalIncident && canManage
|
||||
? await db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } })
|
||||
: [];
|
||||
|
||||
// NIS2-Betroffenheit des Mandanten steuert die Anzeige/Timer (§6) im Detail.
|
||||
const nis2Category =
|
||||
modalIncident && params.detail
|
||||
? (await db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId }, select: { nis2Category: true } }))?.nis2Category ?? "keine"
|
||||
: "keine";
|
||||
|
||||
const filterLink = (patch: Record<string, string | undefined>) => {
|
||||
const sp = new URLSearchParams();
|
||||
const merged = { status: params.status, severity: params.severity, category: params.category, ...patch };
|
||||
for (const [k, v] of Object.entries(merged)) if (v) sp.set(k, v);
|
||||
const qs = sp.toString();
|
||||
return `/incidents${qs ? `?${qs}` : ""}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<a href="/incidents/export?format=register-csv" className="rounded-md border px-3 py-1.5 text-[12px] hover:bg-muted">
|
||||
{t("exportRegisterCsv")}
|
||||
</a>
|
||||
<a href="/incidents/export?format=register-xlsx" className="rounded-md border px-3 py-1.5 text-[12px] hover:bg-muted">
|
||||
{t("exportRegisterXlsx")}
|
||||
</a>
|
||||
{canReport ? (
|
||||
<Button nativeButton={false} render={<Link href="/incidents?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newIncident")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* Filterleiste */}
|
||||
<div className="shadow-card flex flex-wrap items-center gap-2 rounded-xl border bg-card p-3 text-[12px]">
|
||||
<span className="text-muted-foreground">{t("filter")}:</span>
|
||||
<Link href={filterLink({ status: undefined, severity: undefined, category: undefined })} className="rounded-md border px-2 py-1 hover:bg-muted">{t("filterAll")}</Link>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
{INCIDENT_STATUSES.map((s) => (
|
||||
<Link key={s} href={filterLink({ status: params.status === s ? undefined : s })} className={`rounded-md border px-2 py-1 hover:bg-muted ${params.status === s ? "bg-muted font-semibold" : ""}`}>{tStatus(s)}</Link>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
{INCIDENT_SEVERITIES.map((s) => (
|
||||
<Link key={s} href={filterLink({ severity: params.severity === s ? undefined : s })} className={`rounded-md border px-2 py-1 hover:bg-muted ${params.severity === s ? "bg-muted font-semibold" : ""}`}>{tSev(s)}</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Register */}
|
||||
<div className="shadow-card rounded-xl border bg-card">
|
||||
<div className="p-4 pb-0">
|
||||
<SectTitle title={t("register")} sub={t("registerSub")} />
|
||||
</div>
|
||||
<Table className="mt-1">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("id")}</TableHead>
|
||||
<TableHead>{t("incident")}</TableHead>
|
||||
<TableHead>{t("category")}</TableHead>
|
||||
<TableHead>{t("severity")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{incidents.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{incidents.map((inc) => (
|
||||
<TableRow key={inc.id}>
|
||||
<TableCell className="text-muted-foreground">{inc.refNo}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/incidents?detail=${inc.id}`} className="font-bold hover:underline">
|
||||
{inc.restricted ? "🔒 " : ""}{inc.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell><Tag>{tCat(inc.category)}</Tag></TableCell>
|
||||
<TableCell><Pill tone={SEVERITY_TONE[inc.severity as keyof typeof SEVERITY_TONE] ?? "mut"}>{tSev(inc.severity)}</Pill></TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[inc.status as IncidentStatus] ?? "mut"}>{tStatus(inc.status)}</Pill></TableCell>
|
||||
<TableCell className="text-muted-foreground">{inc.owner?.name ?? "—"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalIncident && params.edit && canManage ? (
|
||||
<IncidentEditModal
|
||||
incident={modalIncident}
|
||||
availableAssets={availableAssets}
|
||||
availableProcesses={availableProcesses}
|
||||
availableRisks={availableRisks}
|
||||
availableMeasures={availableMeasures}
|
||||
availableEvidence={availableEvidence}
|
||||
users={users}
|
||||
/>
|
||||
) : modalIncident ? (
|
||||
<IncidentDetailModal
|
||||
incident={modalIncident}
|
||||
audit={auditRows}
|
||||
actorNames={actorNames}
|
||||
users={users}
|
||||
canManage={canManage}
|
||||
canClose={canClose}
|
||||
canReport={canReport}
|
||||
nis2Category={nis2Category}
|
||||
/>
|
||||
) : params.new && canReport ? (
|
||||
<IncidentCreateModal />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
Compass,
|
||||
LayoutDashboard,
|
||||
Boxes,
|
||||
GitBranch,
|
||||
ShieldAlert,
|
||||
ClipboardCheck,
|
||||
ShieldCheck,
|
||||
KanbanSquare,
|
||||
ListChecks,
|
||||
Siren,
|
||||
BookOpenText,
|
||||
MessagesSquare,
|
||||
Network,
|
||||
FolderCheck,
|
||||
Truck,
|
||||
LineChart,
|
||||
Search,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { auth, signOut } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { isTokenStillValid } from "@/server/sessions";
|
||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||
import { HREF_TO_MODULE } from "@/lib/modules";
|
||||
import { AUDIT_READINESS_MODULE_KEY } from "@/lib/audit-readiness/activation";
|
||||
import { resolveTenantBranding } from "@/lib/brand";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NavLink } from "@/components/nav-link";
|
||||
import { TenantBrand } from "@/components/brand/tenant-brand";
|
||||
import { TenantSwitcher } from "@/components/tenant-switcher";
|
||||
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
// WS2: angemeldet, aber (noch) kein aktiver Mandant (mehrere Mitgliedschaften ohne
|
||||
// Slug) → Auswahlseite. MUSS vor jeder dbForTenant-Nutzung stehen (leerer tenantId).
|
||||
if (!session.user.tenantId) redirect("/select-tenant");
|
||||
|
||||
// Kontostatus (autoritativ aus DB, nicht aus dem JWT): deaktivierte Nutzer werden
|
||||
// abgemeldet, ein offener Passwortwechsel wird erzwungen (Force-Change).
|
||||
// Option C (WS4): Membership-Status kommt vom User, die globalen Auth-Zustände
|
||||
// (Passwortzwang, Kill-Switch, MFA, globaler Sperrstatus) von der Identity.
|
||||
const account = await dbForTenant(session.user.tenantId).user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { status: true },
|
||||
});
|
||||
const identity = session.user.identityId
|
||||
? await prisma.identity.findUnique({
|
||||
where: { id: session.user.identityId },
|
||||
select: { status: true, mustChangePassword: true, sessionsValidAfter: true, mfaEnrolledAt: true, uiLocale: true },
|
||||
})
|
||||
: null;
|
||||
// SEC2: serverseitig entwertete Sessions (Passwort-Reset/-Wechsel) sofort abmelden.
|
||||
if (identity && !isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
if (!account || account.status !== "ACTIVE" || !identity || identity.status !== "ACTIVE") {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
||||
<CertviaLogo variant="lockup" theme="dark" height={34} className="mx-auto" />
|
||||
<p className="mt-5 font-heading text-lg font-semibold">Konto deaktiviert</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">Ihr Zugang wurde deaktiviert. Bitte wenden Sie sich an Ihre Administration.</p>
|
||||
<form action={async () => { "use server"; await signOut({ redirectTo: "/login" }); }} className="mt-5">
|
||||
<Button type="submit" variant="outline" className="w-full">Abmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (identity.mustChangePassword) redirect("/change-password");
|
||||
|
||||
// SEC3-a/-b: MFA-Enrollment-Gate. Verlangt der Mandant MFA und hat die Identity weder TOTP
|
||||
// noch einen Passkey, wird die Einrichtung erzwungen (Pflichtseite außerhalb dieses Layouts).
|
||||
if (!identity.mfaEnrolledAt) {
|
||||
const settings = await dbForTenant(session.user.tenantId).tenantSettings.findFirst({ select: { securityPolicy: true } });
|
||||
if (resolveMfaRequired(settings?.securityPolicy)) {
|
||||
const passkeys = session.user.identityId
|
||||
? await prisma.webAuthnCredential.count({ where: { identityId: session.user.identityId } })
|
||||
: 0;
|
||||
if (passkeys === 0) redirect("/enroll-mfa");
|
||||
}
|
||||
}
|
||||
|
||||
const t = await getTranslations("nav");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const nav = [
|
||||
{ href: "/dashboard", label: t("dashboard"), icon: LayoutDashboard, enabled: true },
|
||||
{ href: "/onboarding", label: t("onboarding"), icon: Compass, enabled: true },
|
||||
// M4: nur sichtbar, wenn der Audit-Wizard scharfgeschaltet ist (Opt-in-Flag unten).
|
||||
{ href: "/audit-readiness", label: t("auditReadiness"), icon: ShieldCheck, enabled: true, requiresAudit: true },
|
||||
{ href: "/assets", label: t("assets"), icon: Boxes, enabled: true },
|
||||
{ href: "/processes", label: t("bia"), icon: GitBranch, enabled: true },
|
||||
{ href: "/risks", label: t("risks"), icon: ShieldAlert, enabled: true },
|
||||
{ href: "/soa", label: t("soa"), icon: ClipboardCheck, enabled: true },
|
||||
{ href: "/measures", label: t("measures"), icon: KanbanSquare, enabled: true },
|
||||
{ href: "/tasks", label: t("tasks"), icon: ListChecks, enabled: true },
|
||||
{ href: "/incidents", label: t("incidents"), icon: Siren, enabled: true },
|
||||
{ href: "/policies", label: t("policies"), icon: BookOpenText, enabled: true },
|
||||
{ href: "/chat", label: t("chat"), icon: MessagesSquare, enabled: false },
|
||||
{ href: "/dependencies", label: t("dependencies"), icon: Network, enabled: true },
|
||||
{ href: "/evidence", label: t("evidence"), icon: FolderCheck, enabled: false },
|
||||
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: true },
|
||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: true },
|
||||
];
|
||||
|
||||
// Modul-Gating: deaktivierte Module werden ausgeblendet (§3.4)
|
||||
const [moduleRows, brandingSettings] = await Promise.all([
|
||||
dbForTenant(session.user.tenantId).tenantModule.findMany(),
|
||||
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
|
||||
where: { tenantId: session.user.tenantId },
|
||||
select: { accent: true },
|
||||
}),
|
||||
]);
|
||||
// Certvia ist der Default; ein Mandanten-Logo überschreibt später nur, wenn gesetzt (S8).
|
||||
const branding = resolveTenantBranding(brandingSettings);
|
||||
const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const moduleEnabled = (href: string) => {
|
||||
const key = HREF_TO_MODULE[href];
|
||||
return !key || !disabledModules.has(key);
|
||||
};
|
||||
// M4: Audit-Wizard ist opt-in (Default aus) — nur bei ausdrücklicher Aktivierung sichtbar.
|
||||
const auditEnabled = moduleRows.some((m) => m.moduleKey === AUDIT_READINESS_MODULE_KEY && m.enabled);
|
||||
const visibleNav = nav.filter(
|
||||
(item) =>
|
||||
(!("requiresAudit" in item) || !item.requiresAudit || auditEnabled) &&
|
||||
(!item.enabled || moduleEnabled(item.href)),
|
||||
);
|
||||
|
||||
const canManageTenant = hasPermission(session, "tenant:manage");
|
||||
|
||||
const initials = (session.user.name ?? "?")
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
async function logout() {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/login" });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar">
|
||||
<div className="border-b border-sidebar-border px-4 pt-5 pb-3.5">
|
||||
<Link href="/dashboard" aria-label={branding.productName}>
|
||||
<TenantBrand branding={branding} height={36} />
|
||||
</Link>
|
||||
<TenantSwitcher memberships={session.user.memberships ?? []} activeSlug={session.user.tenantSlug} />
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
||||
{visibleNav.map((item) =>
|
||||
item.enabled ? (
|
||||
<NavLink key={item.href} href={item.href}>
|
||||
<item.icon className="size-[18px] opacity-85" />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
) : (
|
||||
<span
|
||||
key={item.href}
|
||||
title={tc("comingSoon")}
|
||||
className="flex cursor-not-allowed items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] font-semibold text-muted-foreground/50"
|
||||
>
|
||||
<item.icon className="size-[18px]" />
|
||||
{item.label}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{canManageTenant && (
|
||||
<div className="mt-2 space-y-0.5 border-t border-sidebar-border pt-2">
|
||||
<NavLink href="/settings">
|
||||
<Settings className="size-[18px] opacity-85" /> {t("settings")}
|
||||
</NavLink>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||
<form
|
||||
method="GET"
|
||||
action="/assets"
|
||||
className="flex w-full max-w-105 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-muted-foreground"
|
||||
>
|
||||
<Search className="size-4 shrink-0" />
|
||||
<input
|
||||
name="q"
|
||||
placeholder={tc("search")}
|
||||
className="w-full border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex-1" />
|
||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||
<Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
|
||||
<div className="text-right leading-tight">
|
||||
<p className="text-[13px] font-semibold">{session.user.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{session.user.tenantSlug}</p>
|
||||
</div>
|
||||
<div className="bg-grad-soft grid size-9.5 shrink-0 place-items-center rounded-full font-heading text-sm font-bold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
</Link>
|
||||
<form action={logout}>
|
||||
<Button type="submit" variant="ghost" size="sm">
|
||||
{tc("logout")}
|
||||
</Button>
|
||||
</form>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("measures");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { updateMeasureStatus } from "@/server/actions/measures";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { KanbanBoard, type KanbanColumn } from "@/components/kanban-board";
|
||||
import {
|
||||
MeasureCreateModal,
|
||||
MeasureDetailModal,
|
||||
MeasureEditModal,
|
||||
} from "@/components/measure-modals";
|
||||
import { measureRef } from "@/lib/measure";
|
||||
|
||||
const MEASURE_INCLUDE = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
riskMeasures: {
|
||||
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
|
||||
},
|
||||
} as const;
|
||||
|
||||
const PRIORITY_CLASS = {
|
||||
LOW: "bg-[rgba(139,147,173,0.16)] text-muted-foreground",
|
||||
MEDIUM: "bg-[rgba(90,169,230,0.16)] text-[var(--info)]",
|
||||
HIGH: "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]",
|
||||
} as const;
|
||||
|
||||
export default async function MeasuresPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "measure:read");
|
||||
const t = await getTranslations("measures");
|
||||
const tStatus = await getTranslations("measureStatus");
|
||||
const tPrio = await getTranslations("measurePriority");
|
||||
const format = await getFormatter();
|
||||
|
||||
const params = await searchParams;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "measure:write");
|
||||
|
||||
const measures = await db.measure.findMany({
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
_count: { select: { riskMeasures: true } },
|
||||
},
|
||||
orderBy: [{ dueDate: { sort: "asc", nulls: "last" } }, { refNo: "asc" }],
|
||||
take: 300,
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalMeasure = modalId
|
||||
? await db.measure.findUnique({ where: { id: modalId }, include: MEASURE_INCLUDE })
|
||||
: null;
|
||||
|
||||
const users =
|
||||
canWrite && (params.edit || params.new)
|
||||
? await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const now = new Date();
|
||||
const initials = (name?: string | null) =>
|
||||
name
|
||||
? name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
: null;
|
||||
|
||||
const columns: KanbanColumn[] = (["OPEN", "IN_PROGRESS", "DONE"] as const).map((status) => ({
|
||||
status,
|
||||
label: tStatus(status),
|
||||
cards: measures
|
||||
.filter((m) => m.status === status)
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
refLabel: measureRef(m.refNo),
|
||||
title: m.title,
|
||||
dueLabel: m.dueDate ? format.dateTime(m.dueDate, { dateStyle: "medium" }) : null,
|
||||
overdue: !!m.dueDate && m.dueDate < now && m.status !== "DONE",
|
||||
priorityLabel: tPrio(m.priority),
|
||||
priorityClass: PRIORITY_CLASS[m.priority],
|
||||
ownerInitials: initials(m.owner?.name),
|
||||
riskCount: m._count.riskMeasures,
|
||||
})),
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/measures?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newMeasure")}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{canWrite && (
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">{t("dragHint")}</p>
|
||||
)}
|
||||
|
||||
<KanbanBoard columns={columns} canWrite={canWrite} onMove={updateMeasureStatus} />
|
||||
|
||||
{measures.length === 0 && (
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">{t("empty")}</p>
|
||||
)}
|
||||
|
||||
{modalMeasure && params.edit && canWrite ? (
|
||||
<MeasureEditModal measure={modalMeasure} users={users} />
|
||||
) : modalMeasure ? (
|
||||
<MeasureDetailModal measure={modalMeasure} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<MeasureCreateModal users={users} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für den Onboarding-Wizard (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("onboarding");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ObjectReviewStatus } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MODULE_KEYS } from "@/lib/modules";
|
||||
import { getStep, getVisibleSteps, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
|
||||
import "@/lib/onboarding/register-steps"; // Dev-B: echte Schritt-Komponenten einklinken (nach Default-Registrierung)
|
||||
import { advanceTarget, firstIncompleteIndex, isAwaitingValidation, isValidated, progressPercent } from "@/lib/onboarding/state";
|
||||
import { advanceStep, rejectStep, resetStep, validateStep } from "@/server/actions/onboarding";
|
||||
import { BiaPopup } from "@/components/bia-popup";
|
||||
import { ProcessCreateModal, ProcessHouseDetailModal } from "@/components/process-modals";
|
||||
|
||||
const STATUS_TONE: Record<ObjectReviewStatus, "mut" | "warn" | "info" | "ok" | "risk"> = {
|
||||
offen: "mut",
|
||||
in_bearbeitung: "warn",
|
||||
zur_validierung: "info",
|
||||
validiert: "ok",
|
||||
zurueckgewiesen: "risk",
|
||||
};
|
||||
|
||||
/** Beschriftung der Bearbeiter-Aktion je Status (advance/rework); null = keine. */
|
||||
function advanceKey(status: ObjectReviewStatus): "start" | "submit" | "rework" | null {
|
||||
if (!advanceTarget(status)) return null;
|
||||
if (status === "offen") return "start";
|
||||
if (status === "zurueckgewiesen") return "rework";
|
||||
return "submit";
|
||||
}
|
||||
|
||||
export default async function OnboardingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
step?: string;
|
||||
bia?: string;
|
||||
biaStep?: string;
|
||||
biaNew?: string;
|
||||
detail?: string;
|
||||
new?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("onboarding");
|
||||
const tc = await getTranslations("common");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
const canValidate = hasPermission(session, "validate_objects");
|
||||
|
||||
// Sichtbarkeits-Kontext: alle Modul-Keys außer den deaktivierten (Default = aktiv).
|
||||
const moduleRows = await db.tenantModule.findMany();
|
||||
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const ctx: StepCtx = { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
|
||||
const steps = getVisibleSteps(ctx);
|
||||
|
||||
const rows = await db.onboardingProgress.findMany();
|
||||
const byKey = new Map(rows.map((r) => [r.stepKey, r]));
|
||||
const statusOf = (k: StepKey) => byKey.get(k)?.status ?? "offen";
|
||||
|
||||
// Aktueller Schritt: angefragter (jeder Schritt ist zugänglich) sonst Resume-Punkt.
|
||||
const params = await searchParams;
|
||||
const requestedIdx = steps.findIndex((s) => s.key === params.step);
|
||||
const currentIdx = requestedIdx >= 0 ? requestedIdx : firstIncompleteIndex(steps, statusOf);
|
||||
const current = steps[currentIdx];
|
||||
const currentStatus = statusOf(current.key);
|
||||
const currentComment = byKey.get(current.key)?.reviewComment ?? null;
|
||||
const StepComponent = getStep(current.key)!.component;
|
||||
|
||||
const percent = progressPercent(steps, statusOf);
|
||||
const done = steps.filter((s) => isValidated(statusOf(s.key))).length;
|
||||
const advKey = advanceKey(currentStatus);
|
||||
const awaitingValidation = isAwaitingValidation(currentStatus);
|
||||
const prev = currentIdx > 0 ? steps[currentIdx - 1] : null;
|
||||
const next = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
|
||||
|
||||
// TISAX v4B: Prozesshaus-Overlays (Detail read-only / Anlegen). Zustand über
|
||||
// searchParams ?detail=/?new=1 — als Sibling über die Seite gelegt, die
|
||||
// Onboarding-Hauptseite bleibt unverändert.
|
||||
const PROC_BASE = "/onboarding?step=processes";
|
||||
const biaEnabled = ctx.enabledModules.has("bia");
|
||||
const canWriteProcess = canUse && hasPermission(session, "bia:write");
|
||||
|
||||
const detailProcess = params.detail
|
||||
? await db.process.findUnique({
|
||||
where: { id: params.detail },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
parent: { select: { name: true } },
|
||||
children: { select: { id: true, name: true } },
|
||||
bia: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const detailDeputy = detailProcess?.deputyOwnerId
|
||||
? await db.user.findUnique({ where: { id: detailProcess.deputyOwnerId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
const showCreate = Boolean(params.new) && canWriteProcess;
|
||||
let processUsers: { id: string; name: string }[] = [];
|
||||
let processOptions: { id: string; name: string }[] = [];
|
||||
if (showCreate) {
|
||||
[processUsers, processOptions] = await Promise.all([
|
||||
db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("progress", { done, total: steps.length, percent })} />
|
||||
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-[264px_1fr]">
|
||||
{/* Stepper */}
|
||||
<ol className="shadow-card h-fit space-y-1 rounded-xl border bg-card p-2">
|
||||
{steps.map((s, i) => {
|
||||
const st = statusOf(s.key);
|
||||
const isCurrent = i === currentIdx;
|
||||
const inner = (
|
||||
<span className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${isCurrent ? "bg-[var(--surface-soft)] font-semibold" : ""}`}>
|
||||
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[11px] ${isValidated(st) ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "text-muted-foreground"}`}>
|
||||
{isValidated(st) ? <Check className="size-3.5" /> : s.order}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{t(s.title)}</span>
|
||||
<Pill tone={STATUS_TONE[st]}>{t(`status.${st}`)}</Pill>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<Link href={`/onboarding?step=${s.key}`} className="block hover:opacity-90">{inner}</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Aktueller Schritt + Aktionen */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-heading text-lg font-semibold">
|
||||
{current.order}. {t(current.title)}
|
||||
</h2>
|
||||
<Pill tone={STATUS_TONE[currentStatus]}>{t(`status.${currentStatus}`)}</Pill>
|
||||
</div>
|
||||
|
||||
{currentStatus === "zurueckgewiesen" && (
|
||||
<div className="rounded-xl border border-[var(--risk-brd,var(--band-brd))] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
|
||||
<b>{t("rejectedTitle")}</b>
|
||||
{currentComment ? <p className="mt-1">{currentComment}</p> : <p className="mt-1">{t("rejectedNoComment")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StepComponent stepKey={current.key} status={currentStatus} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
{/* Bearbeiter-Aktion */}
|
||||
{canUse && advKey && (
|
||||
<form action={advanceStep.bind(null, current.key)}>
|
||||
<Button type="submit">{t(`actions.${advKey}`)}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Validator-Aktionen (nur zur_validierung + Recht) */}
|
||||
{awaitingValidation && canValidate && (
|
||||
<>
|
||||
<form action={validateStep.bind(null, current.key)}>
|
||||
<Button type="submit">{t("actions.validate")}</Button>
|
||||
</form>
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-9 cursor-pointer list-none items-center rounded-md border border-input px-3 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||||
{t("actions.reject")}
|
||||
</summary>
|
||||
<form action={rejectStep.bind(null, current.key)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
|
||||
<Textarea name="comment" rows={3} placeholder={t("rejectCommentPlaceholder")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{t("actions.reject")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{awaitingValidation && !canValidate && (
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("awaitingValidation")}</span>
|
||||
)}
|
||||
|
||||
{canUse && currentStatus !== "offen" && (
|
||||
<form action={resetStep.bind(null, current.key)}>
|
||||
<Button type="submit" variant="outline">{t("actions.reset")}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{prev && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/onboarding?step=${prev.key}`} />}>
|
||||
{t("actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
{next && (
|
||||
<Button nativeButton={false} render={<Link href={`/onboarding?step=${next.key}`} />}>
|
||||
{t("actions.next")}
|
||||
</Button>
|
||||
)}
|
||||
{!next && isValidated(currentStatus) && <Pill tone="ok">{t("allDone")}</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isValidated(currentStatus) && (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("gateHint")}</p>
|
||||
)}
|
||||
{!canUse && <p className="text-[12.5px] text-muted-foreground">{tc("readOnly")}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* TISAX v3A: geführtes BIA-Popup je Prozess (Prozesshaus-Kachel). Zustand über
|
||||
searchParams ?bia=<id>&biaStep=1..5 — nur bei aktivem BIA-Modul. */}
|
||||
{params.bia && biaEnabled && (
|
||||
<BiaPopup
|
||||
processId={params.bia}
|
||||
step={Number.parseInt(params.biaStep ?? "1", 10) || 1}
|
||||
newForm={params.biaNew}
|
||||
basePath="/onboarding?step=processes"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TISAX v4B: „Details" read-only als Overlay über dem Prozesshaus. */}
|
||||
{detailProcess && (
|
||||
<ProcessHouseDetailModal
|
||||
process={detailProcess}
|
||||
deputyName={detailDeputy?.name ?? null}
|
||||
closeHref={PROC_BASE}
|
||||
biaHref={biaEnabled ? `${PROC_BASE}&bia=${detailProcess.id}&biaStep=1` : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TISAX v4B: „Prozess anlegen" als Overlay (Abbrechen/Schließen kehrt sicher zurück). */}
|
||||
{showCreate && (
|
||||
<ProcessCreateModal
|
||||
users={processUsers}
|
||||
processes={processOptions}
|
||||
closeHref={PROC_BASE}
|
||||
returnTo="house"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Link from "next/link";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { createAssetGapTasks } from "@/server/actions/onboarding";
|
||||
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
|
||||
import { assetTypeLabel } from "@/lib/asset-labels";
|
||||
|
||||
/**
|
||||
* Asset-Schritt (Schritt 5, Story A5-1). Nutzt das bestehende Assetinventar/BIA
|
||||
* (keine Doppel-Datenhaltung): Kennzahlen-Übersicht + Lücken (ohne Eigentümer,
|
||||
* ohne Schutzbedarfsbewertung). Pflege erfolgt im Asset-Modul; Lücken lassen sich
|
||||
* als Aufgaben anlegen.
|
||||
*/
|
||||
export async function AssetsStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [total, withoutOwner, highProtection, unrated, inScope] = await Promise.all([
|
||||
db.asset.count(),
|
||||
db.asset.count({ where: { ownerId: null } }),
|
||||
db.asset.count({ where: { OR: [{ confidentiality: { gte: 3 } }, { integrity: { gte: 3 } }, { availability: { gte: 3 } }] } }),
|
||||
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
|
||||
db.asset.count({ where: { processAssets: { some: {} } } }),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Assets im Inventar", value: total },
|
||||
{ label: "hoher Schutzbedarf", value: highProtection },
|
||||
{ label: "in Prozessen (BIA)", value: inScope },
|
||||
];
|
||||
const gaps = [
|
||||
{ n: withoutOwner, label: "ohne Eigentümer", origin: "wizard:asset-no-owner" },
|
||||
{ n: unrated, label: "ohne Schutzbedarfsbewertung (C/I/A)", origin: "wizard:asset-unrated" },
|
||||
].filter((g) => g.n > 0);
|
||||
|
||||
// Rückmeldung: welche Lücken bereits eine (offene) Aufgabe haben (idempotent, Origin-Abgleich).
|
||||
const gapTasks = await db.task.findMany({
|
||||
where: { origin: { in: gaps.map((g) => g.origin) }, status: { in: ["PROPOSED", "OPEN"] } },
|
||||
select: { origin: true },
|
||||
});
|
||||
const withTask = new Set(gapTasks.map((t) => t.origin));
|
||||
const openGaps = gaps.filter((g) => !withTask.has(g.origin)).length;
|
||||
|
||||
// M2: vorgeschlagene Träger-Asset-Typen (SECONDARY) je übernommenem Katalog-Prozess.
|
||||
const suggestions = await getProcessCatalogSuggestions();
|
||||
const carrierSuggestions = suggestions
|
||||
.map((s) => ({
|
||||
...s,
|
||||
carriers: s.suggestedAssetTypes.filter((t) => t !== "INFORMATION" && t !== "DATA"),
|
||||
}))
|
||||
.filter((s) => s.carriers.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Assetinventar</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<Boxes className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Der Wizard nutzt das bestehende Assetinventar (Schutzbedarf C/I/A, Eigentümer, BIA) —
|
||||
keine doppelte Datenhaltung. Pflege im Asset-Modul.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
|
||||
{canUse && (
|
||||
<form action={createAssetGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={openGaps === 0}>Prüfen & Aufgaben anlegen{openGaps ? ` (${openGaps})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Keine offensichtlichen Lücken (alle Assets mit Eigentümer und Schutzbedarf).</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={withTask.has(g.origin) ? "ok" : "warn"}>{withTask.has(g.origin) ? "Aufgabe angelegt" : "Lücke"}</Pill>
|
||||
<span><b>{g.n}</b> Asset(s) {g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Die Aufgabe stößt die Nacharbeit an; die Lücke selbst schließt sich erst, wenn im Asset-Modul Eigentümer bzw. Schutzbedarf gepflegt sind.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{carrierSuggestions.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Träger-Vorschläge je Prozess (Katalog)</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Typische Träger-Assets (Systeme, Anwendungen, Standorte, Dienstleister …), auf denen
|
||||
die primären Informationswerte dieser Prozesse liegen. Als Orientierung für die
|
||||
Erfassung im Prozess/Asset-Modul.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{carrierSuggestions.map((s) => (
|
||||
<li key={s.processId} className="border-b pb-2 text-[12.5px] last:border-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 font-medium">{s.processName}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/processes?detail=${s.processId}`} />}
|
||||
>
|
||||
Träger erfassen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{s.carriers.map((t) => (
|
||||
<Pill key={t} tone="mut">{assetTypeLabel(t)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { SECTION_TITLES, WIZARD_QUESTIONS, type WizardQuestion, type WizardSection } from "@/lib/onboarding/questions";
|
||||
import { deriveContext } from "@/lib/onboarding/facts";
|
||||
import { evaluateCondition } from "@/lib/rules/engine";
|
||||
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
|
||||
import { saveFacts } from "@/server/actions/onboarding-facts";
|
||||
|
||||
const selectCls = "h-8 w-full max-w-xs rounded-md border bg-background px-2 text-sm";
|
||||
const SECTIONS: WizardSection[] = ["A", "B", "C", "D", "E", "F"];
|
||||
|
||||
function QuestionField({ q, value }: { q: WizardQuestion; value: string | boolean }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">{q.label}</span>
|
||||
{q.help && <span className="mt-0.5 block text-[11.5px] text-muted-foreground">{q.help}</span>}
|
||||
<span className="mt-1 block">
|
||||
{q.type === "boolean" ? (
|
||||
<select name={q.id} defaultValue={String(value === true)} className={selectCls}>
|
||||
<option value="true">Ja</option>
|
||||
<option value="false">Nein</option>
|
||||
</select>
|
||||
) : q.type === "select" ? (
|
||||
<select name={q.id} defaultValue={String(value)} className={selectCls}>
|
||||
{q.choices?.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input name={q.id} defaultValue={String(value)} className="h-8 max-w-xs" />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragebogen (Story B3, Schritt 2 „context"). Dynamischer, bedingter Katalog A–F
|
||||
* (C2). Antworten werden als WizardFacts gespeichert; die Wirkung (Flags, Aufgaben)
|
||||
* propagiert die saveFacts-Action über die B2-Engine. Bedingte Sichtbarkeit wird
|
||||
* serverseitig aus den abgeleiteten Flags/Antworten berechnet.
|
||||
*/
|
||||
export async function ContextStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [facts, scope] = await Promise.all([db.wizardFact.findMany(), db.wizardScope.findFirst()]);
|
||||
// Schutzbedarf-Flags zentral aus dem Assessment-Level (A2-1); Prüfziele zentral aus
|
||||
// WizardScope (Scoping, A2-2) — beides nicht aus dem Fragebogen (keine Doppelquelle).
|
||||
const level = await getAssessmentLevel(db);
|
||||
const ctx = deriveContext(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
|
||||
const current = new Map(facts.map((f) => [f.key, f.value]));
|
||||
|
||||
const valueOf = (q: WizardQuestion): string | boolean =>
|
||||
current.has(q.id) ? (current.get(q.id) as string | boolean) : (q.default ?? (q.type === "boolean" ? false : ""));
|
||||
|
||||
const visible = WIZARD_QUESTIONS.filter((q) => !q.showWhen || evaluateCondition(q.showWhen, ctx));
|
||||
const activeFlags = Object.keys(ctx.flags).filter((k) => ctx.flags[k]).sort();
|
||||
|
||||
return (
|
||||
<form action={saveFacts} className="space-y-4">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Antworten werden als wiederverwendbare Fakten gespeichert. Sie steuern Flags, Klauseln, Controls und
|
||||
Aufgaben-Vorschläge. Organisation, Rollen und Schutzbedarf-Zentralwerte werden in den Einstellungen gepflegt.
|
||||
</p>
|
||||
|
||||
{SECTIONS.map((sec) => {
|
||||
const qs = visible.filter((q) => q.section === sec);
|
||||
if (qs.length === 0) return null;
|
||||
return (
|
||||
<fieldset key={sec} className="rounded-xl border bg-card p-4">
|
||||
<legend className="px-1 text-[13px] font-semibold">{SECTION_TITLES[sec]}</legend>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
{qs.map((q) => <QuestionField key={q.id} q={q} value={valueOf(q)} />)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
{canUse && <Button type="submit">Antworten speichern</Button>}
|
||||
<span className="flex flex-wrap items-center gap-1 text-[12px] text-muted-foreground">
|
||||
Abgeleitete Flags:
|
||||
{activeFlags.length ? activeFlags.map((f) => <Pill key={f} tone="info">{f}</Pill>) : <span>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck, ListChecks } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { controlTitle } from "@/lib/control-titles";
|
||||
import { policyTitles, verfahrenTitles } from "@/lib/policy-titles";
|
||||
import { MATURITY_LABEL, type EvidenceStatus } from "@/lib/maturity";
|
||||
import { buildControlRows, type ControlRow, type HintWithStatus } from "@/server/soa-context";
|
||||
import { confirmControlMaturity, confirmAllSuggestions, createControlGapTasks, setImplementationStatus, createImplementationTask } from "@/server/actions/soa";
|
||||
|
||||
/**
|
||||
* Control-Assessment-Schritt (Schritt 7, Story A7-1/A7-2). Je In-Scope-Control: erwartete
|
||||
* Belege (Richtlinie/Verfahren/Asset/Risiko), abgeleiteter Belegstatus, regelbasierter
|
||||
* Reifegrad-Vorschlag (C5 §2), Zielreifegrad (C5 §3) und offene Punkte (C5 §4). Der
|
||||
* Bearbeiter bestätigt/überschreibt den Reifegrad (Pflicht); Lücken werden zu Aufgaben.
|
||||
*/
|
||||
|
||||
const MATURITY_TONE = ["risk", "orange", "info", "ok"] as const;
|
||||
const STATUS_LABEL: Record<EvidenceStatus, string> = { fehlt: "fehlt", verknuepft: "verknüpft", validiert: "validiert" };
|
||||
const STATUS_TONE: Record<EvidenceStatus, "ok" | "warn" | "mut"> = { fehlt: "mut", verknuepft: "warn", validiert: "ok" };
|
||||
|
||||
const IMPL_STATUSES = ["offen", "in_umsetzung", "erledigt"] as const;
|
||||
const IMPL_LABEL: Record<string, string> = { offen: "offen", in_umsetzung: "in Umsetzung", erledigt: "erledigt" };
|
||||
const IMPL_TONE: Record<string, "mut" | "warn" | "ok"> = { offen: "mut", in_umsetzung: "warn", erledigt: "ok" };
|
||||
const selectCls = "rounded-md border bg-[var(--surface-soft)] px-2 py-1 text-[11.5px]";
|
||||
|
||||
/** Umsetzungshinweise (Spiegelstriche) je Control: Status dokumentieren + Aufgabe erzeugen (#10). */
|
||||
function HintList({ hints, canUse }: { hints: HintWithStatus[]; canUse: boolean }) {
|
||||
const done = hints.filter((h) => h.status === "erledigt").length;
|
||||
return (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11.5px] text-muted-foreground">
|
||||
Umsetzungshinweise ({done}/{hints.length} erledigt)
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{hints.map((h) => (
|
||||
<li key={h.hint.reqId} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone={IMPL_TONE[h.status] ?? "mut"}>{IMPL_LABEL[h.status] ?? h.status}</Pill>
|
||||
<span className="text-[12px] font-medium">{h.hint.requirement}</span>
|
||||
</div>
|
||||
<dl className="mt-1 grid gap-0.5 text-[11px] text-muted-foreground sm:grid-cols-3">
|
||||
<div><b>Organisatorisch:</b> {h.hint.organisational}</div>
|
||||
<div><b>Technisch:</b> {h.hint.technical}</div>
|
||||
<div><b>Nachweise:</b> {h.hint.evidence}</div>
|
||||
</dl>
|
||||
{canUse && (
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<form action={setImplementationStatus.bind(null, h.hint.reqId)} className="flex flex-wrap items-end gap-2">
|
||||
<select name="status" defaultValue={h.status} className={`${selectCls} mt-1`}>
|
||||
{IMPL_STATUSES.map((s) => <option key={s} value={s}>{IMPL_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<Input name="note" defaultValue={h.note ?? ""} placeholder="Notiz / Nachweisverweis" className="h-7 w-56 text-[11.5px]" />
|
||||
<Button type="submit" size="sm" variant="outline">Speichern</Button>
|
||||
</form>
|
||||
<form action={createImplementationTask.bind(null, h.hint.reqId)}>
|
||||
<Button type="submit" size="sm" variant="ghost">Aufgabe</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlLine({ row, hints, canUse }: { row: ControlRow; hints: HintWithStatus[]; canUse: boolean }) {
|
||||
const { spec, evidence, suggestion, target, gaps, confirmed } = row;
|
||||
const belowTarget = (confirmed ?? suggestion.value) < target;
|
||||
const implComplete = hints.length > 0 && hints.every((h) => h.status === "erledigt");
|
||||
return (
|
||||
<li className="border-b py-2.5 last:border-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{row.control}</span>
|
||||
<span className="text-[12.5px] font-medium">{controlTitle(row.control)}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{implComplete && <Pill tone="ok">Nachweis ✓</Pill>}
|
||||
<Pill tone={MATURITY_TONE[suggestion.value]}>Vorschlag {suggestion.value}</Pill>
|
||||
{confirmed !== null && <Pill tone={MATURITY_TONE[confirmed as 0 | 1 | 2 | 3]}>bestätigt {confirmed}</Pill>}
|
||||
<Pill tone={belowTarget ? "warn" : "ok"}>Ziel {target}</Pill>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground" title={suggestion.reason}>
|
||||
{spec.policy.length > 0 && (
|
||||
<span className="flex items-center gap-1">Richtlinie: {policyTitles(spec.policy)} <Pill tone={STATUS_TONE[evidence.policy]}>{STATUS_LABEL[evidence.policy]}</Pill></span>
|
||||
)}
|
||||
{spec.verfahren.length > 0 && (
|
||||
<span className="flex items-center gap-1">· Verfahren: {verfahrenTitles(spec.verfahren)} <Pill tone={STATUS_TONE[evidence.verfahren]}>{STATUS_LABEL[evidence.verfahren]}</Pill></span>
|
||||
)}
|
||||
{spec.needsAsset && <span>· Asset {evidence.assetLinked ? "✓" : "—"}</span>}
|
||||
{spec.needsRisk && <span>· Risiko {evidence.riskLinked ? "✓" : "—"}</span>}
|
||||
</div>
|
||||
{gaps.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{gaps.map((g, i) => (
|
||||
<Pill key={i} tone={g.kind === "widerspruch" ? "risk" : "warn"}>{g.missing}</Pill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && <HintList hints={hints} canUse={canUse} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ControlsStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const { rows, level, implementation } = await buildControlRows(db, session.user.tenantId);
|
||||
const hintsFor = (control: string) => implementation.hintsByControl.get(control) ?? [];
|
||||
const confirmedCount = rows.filter((r) => r.confirmed !== null).length;
|
||||
const openGapCount = rows.reduce((n, r) => n + r.gaps.length, 0);
|
||||
const unconfirmed = rows.filter((r) => r.confirmed === null);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Controls im Scope", value: rows.length },
|
||||
{ label: "bestätigt", value: confirmedCount },
|
||||
{ label: "Umsetzung nachgewiesen", value: implementation.completeControls.size },
|
||||
{ label: "offene Punkte", value: openGapCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> Control-Assessment ({level})</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies" />}>
|
||||
<ListChecks className="size-4" /> Richtlinien
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Der Reifegrad-Vorschlag (0–3) wird regelbasiert aus dem Belegstand der Richtlinien-/Verfahrensdokumente,
|
||||
des Inventars und des Risikoregisters hergeleitet (VDA-ISA, C5). Er ist nicht bindend — die Bestätigung
|
||||
bzw. Überschreibung durch den Bearbeiter ist Pflicht. Je Control lassen sich unten die Umsetzungshinweise
|
||||
(Spiegelstriche) dokumentieren; sind alle erledigt, gilt der Wirksamkeitsnachweis als erbracht und der
|
||||
Vorschlag steigt auf Grad 3. Offene Punkte lassen sich je Hinweis zur Aufgabe machen.
|
||||
</p>
|
||||
{canUse && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<form action={confirmAllSuggestions}>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={unconfirmed.length === 0}>
|
||||
Vorschläge übernehmen{unconfirmed.length ? ` (${unconfirmed.length})` : ""}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={createControlGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Gap-Aufgaben anlegen{openGapCount ? ` (${openGapCount})` : ""}</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canUse && (
|
||||
<form action={confirmControlMaturity} className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Reifegrad bestätigen / überschreiben</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||
<label className="text-[12px]">Control
|
||||
<select name="control" required className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
|
||||
{rows.map((r) => (
|
||||
<option key={r.control} value={r.control}>{r.control} — {controlTitle(r.control)} (Vorschlag {r.suggestion.value})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-[12px]">Reifegrad
|
||||
<select name="value" required defaultValue="" className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
|
||||
<option value="" disabled>wählen…</option>
|
||||
{[0, 1, 2, 3].map((v) => <option key={v} value={v}>{MATURITY_LABEL[v as 0 | 1 | 2 | 3]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-[12px]">Begründung (bei Absenkung)
|
||||
<Textarea name="justification" rows={2} className="mt-1" placeholder="z. B. kompensierende Maßnahme, Restrisiko akzeptiert …" />
|
||||
</label>
|
||||
<div className="mt-3"><Button type="submit" size="sm">Bestätigen</Button></div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Controls im Assessment-Scope</span>
|
||||
<ul className="mt-2">
|
||||
{rows.map((r) => <ControlLine key={r.control} row={r} hints={hintsFor(r.control)} canUse={canUse} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* Risikoakzeptanzkriterien & Bewertungsskala pflegen (Ebene 1 „Fundament").
|
||||
*
|
||||
* Schreibt die drei zentralen Konfigurationsmodelle des Risiko-Fundaments:
|
||||
* - `RiskMatrixClass` : Risikoklassen mit Schwelle (maxScore) + Freigabeinstanz (acceptance)
|
||||
* - `RiskEwLevel` : Eintrittswahrscheinlichkeits-Skala (Stufe/Label/Definition)
|
||||
* - `RiskDamageDimension` : Schadensdimensionen (C/I/A-Bewertungsgrundlage) mit 4 Stufen
|
||||
*
|
||||
* EINE Pflegestelle: Der Wizard-Popup (Schritt 5) und die Einstellungen (/settings) rufen
|
||||
* dieselbe Action und schreiben dieselben Werte. Speicherstrategie: pro Mandant ersetzen
|
||||
* (delete + create in einer Transaktion) — analog zum verwalteten Import.
|
||||
*/
|
||||
|
||||
const toneSchema = z.enum(["ok", "warn", "orange", "risk"]);
|
||||
|
||||
const payloadSchema = z.object({
|
||||
matrixClasses: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Name der Risikoklasse fehlt."),
|
||||
maxScore: z.coerce.number().int().min(1).max(999),
|
||||
acceptance: z.string().trim().min(1, "Freigabeinstanz fehlt."),
|
||||
tone: toneSchema,
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
ewLevels: z
|
||||
.array(
|
||||
z.object({
|
||||
level: z.coerce.number().int().min(1).max(9),
|
||||
label: z.string().trim().min(1, "Label der Wahrscheinlichkeitsstufe fehlt."),
|
||||
definition: z.string().trim().default(""),
|
||||
}),
|
||||
)
|
||||
.max(9),
|
||||
damageDims: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Name der Schadensdimension fehlt."),
|
||||
levels: z.object({
|
||||
"1": z.string().trim().default(""),
|
||||
"2": z.string().trim().default(""),
|
||||
"3": z.string().trim().default(""),
|
||||
"4": z.string().trim().default(""),
|
||||
"5": z.string().trim().default(""),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export async function saveRiskCriteria(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
// Fundament-Pflege im Wizard und in den Einstellungen — beide Einstiege haben onboarding:use
|
||||
// (Mandanten-Admin und ISB). Autoritative Prüfung (wirft ForbiddenError bei fehlendem Recht).
|
||||
requirePermission(session, "onboarding:use");
|
||||
const tenantId = session.user.tenantId;
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
const raw = String(formData.get("payload") ?? "");
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Ungültige Kriteriendaten.");
|
||||
}
|
||||
const { matrixClasses, ewLevels, damageDims } = payloadSchema.parse(parsedJson);
|
||||
|
||||
await db.$transaction([
|
||||
db.riskMatrixClass.deleteMany({}),
|
||||
db.riskMatrixClass.createMany({
|
||||
data: matrixClasses.map((c, i) => ({
|
||||
tenantId,
|
||||
name: c.name,
|
||||
maxScore: c.maxScore,
|
||||
acceptance: c.acceptance,
|
||||
tone: c.tone,
|
||||
orderIdx: i,
|
||||
})),
|
||||
}),
|
||||
db.riskEwLevel.deleteMany({}),
|
||||
db.riskEwLevel.createMany({
|
||||
data: ewLevels.map((e) => ({ tenantId, level: e.level, label: e.label, definition: e.definition })),
|
||||
}),
|
||||
db.riskDamageDimension.deleteMany({}),
|
||||
db.riskDamageDimension.createMany({
|
||||
data: damageDims.map((d, i) => ({ tenantId, name: d.name, levels: d.levels, orderIdx: i })),
|
||||
}),
|
||||
]);
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "risk_criteria",
|
||||
after: {
|
||||
matrixClasses: matrixClasses.length,
|
||||
ewLevels: ewLevels.length,
|
||||
damageDims: damageDims.length,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/onboarding");
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/risks");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Popup } from "../roles/popup";
|
||||
import {
|
||||
CriteriaEditor,
|
||||
type MatrixClassRow,
|
||||
type EwLevelRow,
|
||||
type DamageDimRow,
|
||||
} from "./criteria-editor";
|
||||
|
||||
/**
|
||||
* „Kriterien bearbeiten“ direkt im Wizard (Schritt 5). Öffnet den gemeinsamen
|
||||
* `CriteriaEditor` im Popup (Optik wie Risiken-Popup). Schreibt über dieselbe
|
||||
* Action wie die Einstellungen — eine Pflegestelle.
|
||||
*/
|
||||
export function CriteriaEditDialog({
|
||||
matrixClasses,
|
||||
ewLevels,
|
||||
damageDims,
|
||||
}: {
|
||||
matrixClasses: MatrixClassRow[];
|
||||
ewLevels: EwLevelRow[];
|
||||
damageDims: DamageDimRow[];
|
||||
}) {
|
||||
return (
|
||||
<Popup
|
||||
triggerLabel="Kriterien bearbeiten"
|
||||
triggerIcon={<Pencil className="size-4" />}
|
||||
triggerVariant="default"
|
||||
triggerSize="sm"
|
||||
title="Risikoakzeptanzkriterien & Bewertungsskala bearbeiten"
|
||||
sub="Risikoklassen, Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) — zentral gepflegt."
|
||||
maxWidthClass="max-w-3xl"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5">
|
||||
<CriteriaEditor
|
||||
initialMatrixClasses={matrixClasses}
|
||||
initialEwLevels={ewLevels}
|
||||
initialDamageDims={damageDims}
|
||||
onSaved={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Trash2, ShieldCheck, Scale, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { saveRiskCriteria } from "./actions";
|
||||
|
||||
export type Tone = "ok" | "warn" | "orange" | "risk";
|
||||
|
||||
export interface MatrixClassRow {
|
||||
name: string;
|
||||
maxScore: number;
|
||||
acceptance: string;
|
||||
tone: Tone;
|
||||
}
|
||||
export interface EwLevelRow {
|
||||
level: number;
|
||||
label: string;
|
||||
definition: string;
|
||||
}
|
||||
export interface DamageDimRow {
|
||||
name: string;
|
||||
levels: { "1": string; "2": string; "3": string; "4": string; "5": string };
|
||||
}
|
||||
|
||||
const TONE_OPTIONS: { value: Tone; label: string }[] = [
|
||||
{ value: "ok", label: "grün (niedrig)" },
|
||||
{ value: "warn", label: "gelb (mittel)" },
|
||||
{ value: "orange", label: "orange (hoch)" },
|
||||
{ value: "risk", label: "rot (kritisch)" },
|
||||
];
|
||||
|
||||
const inputCls = "h-8 text-[12.5px]";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Editor für Risikoakzeptanzkriterien & Bewertungsskala. Wird sowohl im
|
||||
* Wizard-Popup (Schritt 5) als auch inline in den Einstellungen verwendet und schreibt
|
||||
* über dieselbe Server-Action `saveRiskCriteria` in dieselben Modelle (eine Pflegestelle).
|
||||
*/
|
||||
export function CriteriaEditor({
|
||||
initialMatrixClasses,
|
||||
initialEwLevels,
|
||||
initialDamageDims,
|
||||
onSaved,
|
||||
}: {
|
||||
initialMatrixClasses: MatrixClassRow[];
|
||||
initialEwLevels: EwLevelRow[];
|
||||
initialDamageDims: DamageDimRow[];
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [matrixClasses, setMatrixClasses] = useState<MatrixClassRow[]>(initialMatrixClasses);
|
||||
const [ewLevels, setEwLevels] = useState<EwLevelRow[]>(initialEwLevels);
|
||||
const [damageDims, setDamageDims] = useState<DamageDimRow[]>(initialDamageDims);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, start] = useTransition();
|
||||
|
||||
function updateMatrix(idx: number, patch: Partial<MatrixClassRow>) {
|
||||
setMatrixClasses((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateEw(idx: number, patch: Partial<EwLevelRow>) {
|
||||
setEwLevels((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateDim(idx: number, patch: Partial<DamageDimRow>) {
|
||||
setDamageDims((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateDimLevel(idx: number, key: "1" | "2" | "3" | "4" | "5", value: string) {
|
||||
setDamageDims((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, levels: { ...r.levels, [key]: value } } : r)),
|
||||
);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
start(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("payload", JSON.stringify({ matrixClasses, ewLevels, damageDims }));
|
||||
await saveRiskCriteria(fd);
|
||||
setSaved(true);
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Risikoklassen / Akzeptanzkriterien */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<Scale className="size-4" /> Risikoklassen & Akzeptanz
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setMatrixClasses((r) => [...r, { name: "", maxScore: 0, acceptance: "", tone: "warn" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Klasse
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
Schwelle (bis Risikowert) und Freigabeinstanz je Risikoklasse.
|
||||
</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="grid grid-cols-[1fr_5rem_1.4fr_7rem_2rem] gap-2 px-1 text-[10.5px] font-medium text-muted-foreground max-sm:hidden">
|
||||
<span>Risikoklasse</span>
|
||||
<span>bis Wert</span>
|
||||
<span>Akzeptanz / Freigabeinstanz</span>
|
||||
<span>Farbe</span>
|
||||
<span />
|
||||
</div>
|
||||
{matrixClasses.map((c, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_5rem_1.4fr_7rem_2rem]">
|
||||
<Input
|
||||
value={c.name}
|
||||
onChange={(e) => updateMatrix(i, { name: e.target.value })}
|
||||
placeholder="Niedrig"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={c.maxScore}
|
||||
onChange={(e) => updateMatrix(i, { maxScore: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
value={c.acceptance}
|
||||
onChange={(e) => updateMatrix(i, { acceptance: e.target.value })}
|
||||
placeholder="Akzeptanz durch …"
|
||||
className={inputCls}
|
||||
/>
|
||||
<select
|
||||
value={c.tone}
|
||||
onChange={(e) => updateMatrix(i, { tone: e.target.value as Tone })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-[12px]"
|
||||
>
|
||||
{TONE_OPTIONS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Klasse entfernen"
|
||||
onClick={() => setMatrixClasses((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{matrixClasses.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Risikoklassen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Eintrittswahrscheinlichkeit */}
|
||||
<section className="border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<Activity className="size-4" /> Eintrittswahrscheinlichkeit
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setEwLevels((r) => [...r, { level: r.length + 1, label: "", definition: "" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Stufe
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{ewLevels.map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[3.5rem_1fr_1.6fr_2rem]">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={e.level}
|
||||
onChange={(ev) => updateEw(i, { level: Number(ev.target.value) })}
|
||||
className={inputCls}
|
||||
aria-label="Stufe"
|
||||
/>
|
||||
<Input
|
||||
value={e.label}
|
||||
onChange={(ev) => updateEw(i, { label: ev.target.value })}
|
||||
placeholder="Label"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
value={e.definition}
|
||||
onChange={(ev) => updateEw(i, { definition: ev.target.value })}
|
||||
placeholder="Definition"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Stufe entfernen"
|
||||
onClick={() => setEwLevels((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ewLevels.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Stufen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Schadensdimensionen (C/I/A-Bewertungsgrundlage) */}
|
||||
<section className="border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<ShieldCheck className="size-4" /> Schadensdimensionen (C/I/A-Skala)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDamageDims((r) => [
|
||||
...r,
|
||||
{ name: "", levels: { "1": "", "2": "", "3": "", "4": "", "5": "" } },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Dimension
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
Bewertungsgrundlage der Schadensstufen 1–5 je Dimension.
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{damageDims.map((d, i) => (
|
||||
<div key={i} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={d.name}
|
||||
onChange={(e) => updateDim(i, { name: e.target.value })}
|
||||
placeholder="Name der Dimension"
|
||||
className={`${inputCls} font-medium`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Dimension entfernen"
|
||||
onClick={() => setDamageDims((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{(["1", "2", "3", "4", "5"] as const).map((k) => (
|
||||
<div key={k}>
|
||||
<span className="text-[10.5px] text-muted-foreground">Stufe {k}</span>
|
||||
<Input
|
||||
value={d.levels[k]}
|
||||
onChange={(e) => updateDimLevel(i, k, e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{damageDims.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Schadensdimensionen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
||||
<Button type="button" onClick={submit} disabled={pending}>
|
||||
{pending ? "Speichern …" : "Speichern"}
|
||||
</Button>
|
||||
{saved && !error && <span className="text-[12px] text-[var(--ok)]">Gespeichert.</span>}
|
||||
{error && <span className="text-[12px] text-destructive">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import { Settings, ShieldCheck, Scale } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getAssessmentLevel } from "@/server/assessment-level";
|
||||
import { CriteriaEditDialog } from "./criteria-edit-dialog";
|
||||
import type { Tone } from "./criteria-editor";
|
||||
|
||||
/**
|
||||
* Risikoakzeptanzkriterien & C/I/A-Skala (Ebene 1 „Fundament", M1). Als Fundament-
|
||||
* Artefakt festgehalten: die zentrale Schutzbedarfsskala (C/I/A) und die
|
||||
* Risikoakzeptanzkriterien (Bewertungsmatrix + Akzeptanzinstanzen). Beides wird
|
||||
* zentral im Risiko-Modul/den Einstellungen gepflegt (keine Doppel-Datenhaltung) —
|
||||
* hier zusammengeführt, damit das Fundament vollständig und prüffähig ist.
|
||||
*/
|
||||
export async function CriteriaStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canEdit = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [level, damageDims, matrixClasses, ewLevels] = await Promise.all([
|
||||
getAssessmentLevel(db),
|
||||
db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||
]);
|
||||
const veryHigh = level === "AL3";
|
||||
|
||||
// Serialisierbare Editor-Daten (Popup + Einstellungen teilen sich denselben Editor).
|
||||
const lvl = (v: unknown, k: string) => {
|
||||
const rec = (v ?? {}) as Record<string, unknown>;
|
||||
return typeof rec[k] === "string" ? (rec[k] as string) : "";
|
||||
};
|
||||
const editorMatrix = matrixClasses.map((c) => ({
|
||||
name: c.name,
|
||||
maxScore: c.maxScore,
|
||||
acceptance: c.acceptance,
|
||||
tone: (["ok", "warn", "orange", "risk"].includes(c.tone) ? c.tone : "warn") as Tone,
|
||||
}));
|
||||
const editorEw = ewLevels.map((e) => ({ level: e.level, label: e.label, definition: e.definition }));
|
||||
const editorDims = damageDims.map((d) => ({
|
||||
name: d.name,
|
||||
levels: {
|
||||
"1": lvl(d.levels, "1"),
|
||||
"2": lvl(d.levels, "2"),
|
||||
"3": lvl(d.levels, "3"),
|
||||
"4": lvl(d.levels, "4"),
|
||||
"5": lvl(d.levels, "5"),
|
||||
},
|
||||
}));
|
||||
|
||||
// Standard-Schutzbedarfsstufen (C/I/A). „sehr hoch" nur bei AL3 (Assessment-Level).
|
||||
const ciaLevels = [
|
||||
{ key: "normal", label: "normal", desc: "Begrenzte Auswirkungen; Standard-Schutz genügt." },
|
||||
{ key: "hoch", label: "hoch", desc: "Beträchtliche Auswirkungen; erhöhter Schutzbedarf." },
|
||||
...(veryHigh ? [{ key: "sehr_hoch", label: "sehr hoch", desc: "Existenzbedrohende/katastrophale Auswirkungen; höchster Schutzbedarf." }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* C/I/A-Schutzbedarfsskala */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> C/I/A-Schutzbedarfsskala</span>
|
||||
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Einheitliche Skala für Vertraulichkeit (C), Integrität (I) und Verfügbarkeit (A). Die Stufe
|
||||
„sehr hoch“ ist {veryHigh ? "durch das Assessment-Level AL3 aktiv" : "erst ab Assessment-Level AL3 relevant"}.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
{ciaLevels.map((l) => (
|
||||
<div key={l.key} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[13px] font-semibold">{l.label}</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">{l.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{damageDims.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-[11.5px] font-medium text-muted-foreground">Schadensdimensionen (Bewertungsgrundlage):</p>
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{damageDims.map((d) => (
|
||||
<li key={d.id}><Pill tone="info">{d.name}</Pill></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risikoakzeptanzkriterien */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><Scale className="size-4" /> Risikoakzeptanzkriterien</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<CriteriaEditDialog matrixClasses={editorMatrix} ewLevels={editorEw} damageDims={editorDims} />
|
||||
)}
|
||||
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
|
||||
<Settings className="size-3.5" /> in Einstellungen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matrixClasses.length > 0 ? (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-medium">Risikoklasse</th>
|
||||
<th className="py-1.5 pr-3 font-medium">bis Risikowert</th>
|
||||
<th className="py-1.5 font-medium">Akzeptanz / Freigabeinstanz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrixClasses.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-3"><b>{c.name}</b></td>
|
||||
<td className="py-1.5 pr-3 tabular-nums">{c.maxScore}</td>
|
||||
<td className="py-1.5">{c.acceptance}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Noch keine Risikobewertungsmatrix hinterlegt. Die Akzeptanzkriterien (Schwellen und
|
||||
Freigabeinstanzen je Risikoklasse) werden zentral gepflegt und hier als Fundament-Artefakt gespiegelt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{ewLevels.length > 0 && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
Eintrittswahrscheinlichkeit: {ewLevels.map((e) => `${e.level} = ${e.label}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Risikoeigner akzeptieren Restrisiken gemäß dieser Kriterien; Ausnahmen oberhalb der
|
||||
Akzeptanzschwelle erfordern eine dokumentierte Managemententscheidung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Search, Plus, Link2, AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { InfoLabel } from "@prisma/client";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { infoLabelLabel } from "@/lib/asset-labels";
|
||||
import {
|
||||
searchAssets,
|
||||
addPrimaryInformation,
|
||||
linkPrimaryInformation,
|
||||
type AssetSuggestion,
|
||||
} from "@/server/actions/structure";
|
||||
import {
|
||||
nameSimilarity,
|
||||
normalizeAssetName,
|
||||
FUZZY_SIMILARITY_THRESHOLD,
|
||||
} from "@/lib/normalize-asset";
|
||||
|
||||
const INFO_LABELS: { value: string; label: string }[] = [
|
||||
{ value: "NONE", label: "ohne" },
|
||||
{ value: "INFO_HIGH", label: "Info hoch" },
|
||||
{ value: "INFO_VERY_HIGH", label: "Info sehr hoch" },
|
||||
{ value: "PROTOTYPE", label: "Prototyp" },
|
||||
{ value: "PERSONAL_DATA", label: "personenbezogen" },
|
||||
];
|
||||
|
||||
/**
|
||||
* M2 §2.1 — Combobox zur prozessgeführten Erfassung PRIMÄRER Informations-Assets
|
||||
* mit Dedup + Autovervollständigung.
|
||||
*
|
||||
* - As-you-type ruft `searchAssets` (server) → Vorschläge (type INFORMATION/DATA),
|
||||
* inkl. „bereits erfasst in Prozess X". Ein Klick VERKNÜPFT das bestehende Asset
|
||||
* (linkPrimaryInformation) statt ein Duplikat anzulegen.
|
||||
* - Weiche Fuzzy-Warnung: in-app Levenshtein (`nameSimilarity`) auf der
|
||||
* Kandidatenliste — warnt „Meintest du …?" VOR dem Anlegen.
|
||||
* - Exakt-Dedup erzwingt zusätzlich die DB (@@unique) im Schreibpfad.
|
||||
*/
|
||||
export function InformationCombobox({
|
||||
processId,
|
||||
suggestedLabels = [],
|
||||
hideType = false,
|
||||
hideLabel = false,
|
||||
}: {
|
||||
processId: string;
|
||||
/** Katalog-Vorschläge (suggestedInfoLabels) des Prozesses — als Ein-Klick-Defaults. */
|
||||
suggestedLabels?: InfoLabel[];
|
||||
/** BIA-Info-Schritt: Typ-Auswahl (Information/Daten) ausblenden — immer INFORMATION. */
|
||||
hideType?: boolean;
|
||||
/** BIA-Info-Schritt: Klassifizierungs-Dropdown ausblenden — Schutzstufe folgt aus C/I/A. */
|
||||
hideLabel?: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [type, setType] = useState<"INFORMATION" | "DATA">("INFORMATION");
|
||||
const [label, setLabel] = useState("NONE");
|
||||
const [results, setResults] = useState<AssetSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searching, startSearch] = useTransition();
|
||||
const [saving, startSave] = useTransition();
|
||||
const seq = useRef(0);
|
||||
|
||||
// Debounced As-you-type-Suche gegen das Tenant-Asset-Register. Das Leeren/Suchen
|
||||
// läuft im Timeout-Callback (nicht synchron im Effect-Body), um kaskadierende
|
||||
// Renders zu vermeiden (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
const mySeq = ++seq.current;
|
||||
const handle = setTimeout(() => {
|
||||
if (q.length < 2) {
|
||||
if (mySeq === seq.current) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
startSearch(async () => {
|
||||
const rows = await searchAssets(q);
|
||||
if (mySeq === seq.current) {
|
||||
setResults(rows);
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
const normQuery = normalizeAssetName(query);
|
||||
const exactHit = results.find((r) => r.normalizedName === normQuery && normQuery.length > 0);
|
||||
// Nah-Duplikate (weiche Warnung): ähnlich, aber nicht exakt gleich.
|
||||
const fuzzyHits = results
|
||||
.filter((r) => r.normalizedName !== normQuery)
|
||||
.map((r) => ({ r, sim: nameSimilarity(query, r.name) }))
|
||||
.filter((x) => x.sim >= FUZZY_SIMILARITY_THRESHOLD)
|
||||
.sort((a, b) => b.sim - a.sim)
|
||||
.slice(0, 3);
|
||||
|
||||
function link(assetId: string) {
|
||||
startSave(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("assetId", assetId);
|
||||
await linkPrimaryInformation(processId, fd);
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
if (!normQuery) return;
|
||||
startSave(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("name", query.trim());
|
||||
fd.set("type", type);
|
||||
fd.set("label", label);
|
||||
await addPrimaryInformation(processId, fd);
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
setLabel("NONE");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
placeholder="Informationswert erfassen (z. B. Kundendaten) …"
|
||||
className="pl-7"
|
||||
aria-label="Informationswert suchen oder erfassen"
|
||||
/>
|
||||
</div>
|
||||
{!hideType && (
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as "INFORMATION" | "DATA")}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||
aria-label="Werttyp"
|
||||
>
|
||||
<option value="INFORMATION">Information</option>
|
||||
<option value="DATA">Daten</option>
|
||||
</select>
|
||||
)}
|
||||
{!hideLabel && (
|
||||
<select
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||
aria-label="Klassifizierungslabel"
|
||||
>
|
||||
{INFO_LABELS.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={createNew}
|
||||
disabled={saving || !normQuery || !!exactHit}
|
||||
>
|
||||
<Plus className="size-4" /> Neu anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Katalog-Vorschläge (M2): suggestedInfoLabels des Prozesses als Ein-Klick-Default. */}
|
||||
{!hideLabel && suggestedLabels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Sparkles className="size-3.5" /> Katalog-Vorschlag:
|
||||
</span>
|
||||
{suggestedLabels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLabel(l)}
|
||||
className={`rounded-full border px-2 py-0.5 hover:bg-muted ${
|
||||
label === l ? "border-[var(--brand)] bg-muted font-medium" : ""
|
||||
}`}
|
||||
aria-pressed={label === l}
|
||||
>
|
||||
{infoLabelLabel(l)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weiche Fuzzy-Warnung (§2.1): Nah-Duplikate vor dem Anlegen. */}
|
||||
{!exactHit && fuzzyHits.length > 0 && normQuery.length >= 2 && (
|
||||
<div className="mt-2 rounded-lg border border-[var(--band-brd,var(--warn))] bg-[var(--band,transparent)] p-2 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="size-3.5 text-[var(--warn)]" /> Ähnlicher Wert vorhanden — meintest du:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{fuzzyHits.map(({ r, sim }) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => link(r.id)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<Link2 className="size-3" /> {r.name}{" "}
|
||||
<span className="text-muted-foreground">({Math.round(sim * 100)}%)</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autocomplete-Liste */}
|
||||
{open && results.length > 0 && (
|
||||
<ul className="shadow-card absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-xl border bg-card p-1 text-sm">
|
||||
{results.map((r) => {
|
||||
const isExact = r.normalizedName === normQuery && normQuery.length > 0;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => link(r.id)}
|
||||
disabled={saving}
|
||||
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-soft)]"
|
||||
>
|
||||
<Link2 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{r.type}</span>
|
||||
{r.processes.length > 0 && (
|
||||
<span className="mt-0.5 block text-[11px] text-muted-foreground">
|
||||
bereits erfasst in: {r.processes.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isExact ? <Pill tone="ok">verknüpfen</Pill> : <Pill tone="info">vorhanden</Pill>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{searching && query.trim().length >= 2 && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">suche …</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { Database } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
|
||||
import { InformationCombobox } from "./information-combobox";
|
||||
|
||||
/**
|
||||
* M2 Schritt „Information" (Ebene 2). Kernprinzip: Ein Informationswert IST ein
|
||||
* primäres Asset (type INFORMATION/DATA) — kein Extra-Objekt. Je Prozess werden hier
|
||||
* die primären Informations-Assets erfasst, mit Dedup + Autovervollständigung (§2.1,
|
||||
* Combobox). Träger-Assets folgen im nächsten Schritt via ProcessAsset(SECONDARY).
|
||||
*/
|
||||
export async function InformationStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const processes = await db.process.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
processAssets: {
|
||||
where: { role: "PRIMARY", asset: { type: { in: ["INFORMATION", "DATA"] } } },
|
||||
select: { asset: { select: { id: true, name: true, type: true, label: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalPrimary = await db.asset.count({ where: { type: { in: ["INFORMATION", "DATA"] } } });
|
||||
const withoutProcess = processes.filter((p) => p.processAssets.length === 0).length;
|
||||
|
||||
// M2: Katalog-Vorschläge (suggestedInfoLabels) je übernommenem Prozess als Defaults.
|
||||
const suggestions = await getProcessCatalogSuggestions();
|
||||
const labelsByProcess = new Map(
|
||||
suggestions.map((s) => [s.processId, s.suggestedInfoLabels.filter((l) => l !== "NONE")]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Informationswerte je Prozess</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<Database className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Ein Informationswert ist ein <b>primäres Asset</b> (Information/Daten) — kein Extra-Objekt.
|
||||
Erfassen mit Dedup: gleiche Werte (auch bei anderer Schreibweise) werden verknüpft statt
|
||||
doppelt angelegt. Ähnliche Werte werden vor dem Anlegen gewarnt.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: "Prozesse", value: processes.length },
|
||||
{ label: "primäre Informationswerte", value: totalPrimary },
|
||||
{ label: "Prozesse ohne Informationswert", value: withoutProcess },
|
||||
].map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">
|
||||
Noch keine Prozesse. Lege im vorigen Schritt „Prozesse“ welche an (Katalog oder eigene),
|
||||
dann erfasse hier die zugehörigen Informationswerte.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{processes.map((p) => (
|
||||
<div key={p.id} className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">{p.name}</span>
|
||||
<Pill tone="mut">{p.category}</Pill>
|
||||
</div>
|
||||
{p.processAssets.length > 0 ? (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{p.processAssets.map((pa) => (
|
||||
<li
|
||||
key={pa.asset.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border bg-[var(--surface-soft)] px-2 py-0.5 text-[12px]"
|
||||
>
|
||||
{pa.asset.name}
|
||||
<span className="text-[10.5px] text-muted-foreground">{pa.asset.type}</span>
|
||||
{pa.asset.label !== "NONE" && <Pill tone="warn">{pa.asset.label}</Pill>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[12px] text-muted-foreground">
|
||||
Noch kein primärer Informationswert erfasst.
|
||||
</p>
|
||||
)}
|
||||
{canUse && (
|
||||
<div className="mt-3">
|
||||
<InformationCombobox
|
||||
processId={p.id}
|
||||
suggestedLabels={labelsByProcess.get(p.id) ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { StepProps } from "@/lib/onboarding/registry";
|
||||
|
||||
/**
|
||||
* Platzhalter-Schritt (Story A1-1). Die echten Schritt-Komponenten werden über die
|
||||
* Step-Registry eingeklinkt: `scoping` (A2), `context`/`policies` (Dev B), weitere Schritte
|
||||
* in Folge-Stories. Bis dahin rendert die Shell diesen Platzhalter, damit Navigation,
|
||||
* State-Machine und Gate bereits vollständig demonstrierbar sind.
|
||||
*/
|
||||
export async function PlaceholderStep({ stepKey }: StepProps) {
|
||||
const t = await getTranslations("onboarding");
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed bg-[var(--surface-soft)] p-6 text-sm text-muted-foreground">
|
||||
<p className="font-heading text-[15px] font-semibold text-foreground">{t(`stepTitle.${stepKey}`)}</p>
|
||||
<p className="mt-1">{t("placeholderNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, FileCheck2 } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { PolicyDomainView } from "@/components/policy-domain-view";
|
||||
import { createPolicyGapTasks } from "@/server/actions/onboarding-steps";
|
||||
|
||||
/**
|
||||
* Richtlinien-/Leitlinien-Schritt (Ebene 1 „Fundament", M1). Setzt auf dem bestehenden
|
||||
* Richtlinienmodul auf (Import/Coverage/Freigabe, Stories B4–B6) — keine Doppel-
|
||||
* Datenhaltung. Die Informationssicherheits-Leitlinie & das Management-Commitment
|
||||
* werden als Fundament-Artefakt gespiegelt (kein Cockpit-Task); zusätzlich Übernahme-/
|
||||
* Freigabestand des Pakets und Aufgaben für Lücken.
|
||||
*/
|
||||
export async function PoliciesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
// Fachbereichs-Bearbeitung (Zuordnung/Editieren/Zur-Prüfung) nur mit policy:write —
|
||||
// sonst wird die Ansicht rein lesend gerendert.
|
||||
const canWritePolicies = hasPermission(session, "policy:write");
|
||||
|
||||
const [leitlinie, state, total, freigegeben, entwurf, openApprovals, requirements] = await Promise.all([
|
||||
db.policyDocument.findFirst({ where: { type: "LEITLINIE", archivedAt: null }, select: { code: true, title: true, status: true, version: true, approvedAt: true } }),
|
||||
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null, status: "FREIGEGEBEN" } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
|
||||
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
|
||||
db.policyRequirement.count({ where: { archivedAt: null } }),
|
||||
]);
|
||||
|
||||
const leitlinieFreigegeben = leitlinie?.status === "FREIGEGEBEN";
|
||||
|
||||
const kpis = [
|
||||
{ label: "Dokumente", value: total },
|
||||
{ label: "freigegeben", value: freigegeben },
|
||||
{ label: "abgedeckte Anforderungen", value: requirements },
|
||||
];
|
||||
const gaps = [
|
||||
!state?.importedVersion ? { label: "Vorlagenpaket noch nicht übernommen (Paket-Updates)", tone: "warn" as const } : null,
|
||||
entwurf > 0 ? { label: `${entwurf} Richtlinie(n) im Entwurf`, tone: "warn" as const } : null,
|
||||
openApprovals > 0 ? { label: `${openApprovals} offene Freigabe(n)`, tone: "info" as const } : null,
|
||||
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Leitlinie & Management-Commitment — Fundament-Artefakt (kein Cockpit-Task) */}
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><FileCheck2 className="size-4" /> Informationssicherheits-Leitlinie & Management-Commitment</span>
|
||||
{leitlinie ? (
|
||||
<Pill tone={leitlinieFreigegeben ? "ok" : "warn"}>{leitlinieFreigegeben ? "freigegeben" : "in Arbeit"}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">nicht vorhanden</Pill>
|
||||
)}
|
||||
</div>
|
||||
{leitlinie ? (
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
<b>{leitlinie.code} · {leitlinie.title}</b> (v{leitlinie.version})
|
||||
{leitlinieFreigegeben
|
||||
? " — von der Leitung freigegeben; dokumentiert das Management-Commitment zum ISMS."
|
||||
: " — noch nicht freigegeben. Die Freigabe durch die oberste Leitung ist das Fundament (Verpflichtungserklärung)."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
Es ist noch keine Leitlinie (L00) angelegt. Sie ist das Fundament-Artefakt der Ebene 1 —
|
||||
die oberste Leitung verpflichtet sich darin zum ISMS. Über das Richtlinien-Modul anlegen/übernehmen.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies${leitlinie?.code ? `/${leitlinie.code}` : ""}`} />}>
|
||||
<BookOpen className="size-4" /> Leitlinie öffnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Richtlinien & Verfahren</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/updates" />}>Paket-Updates</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies?view=domains" />}><BookOpen className="size-4" /> Zu den Richtlinien</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Übernommene Paketversion: {state?.importedVersion ?? "—"}. Der Wizard nutzt das bestehende
|
||||
Richtlinienmodul (Import/Upload, Coverage, Vier-Augen-Freigabe) — hier direkt nach Fachbereich.
|
||||
</p>
|
||||
|
||||
{/* Richtlinien nach Fachbereich — Zuordnung, Bearbeiten, „Zur Prüfung geben" direkt im Schritt.
|
||||
Ohne policy:write rein lesend (geteilte Komponente, identisch zu /policies?view=domains). */}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<p className="font-heading text-[13px] font-semibold">Richtlinien nach Fachbereich</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{canWritePolicies
|
||||
? "Fachbereich zuordnen, bearbeiten und zur Prüfung geben — aus dem primären Control abgeleitet, manuell überschreibbar."
|
||||
: "Übersicht nach Fachbereich (Lesezugriff). Zum Bearbeiten fehlt die Berechtigung „policy:write“."}
|
||||
</p>
|
||||
<PolicyDomainView db={db} currentUserId={session.user.id} canWrite={canWritePolicies} compact returnTo="/onboarding?step=policy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
|
||||
{canUse && (
|
||||
<form action={createPolicyGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Paket übernommen, keine Entwürfe/offenen Freigaben — Richtlinienstand ist assessment-tauglich.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
|
||||
<span>{g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Plus, Trash2, Workflow } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CriticalityPill, OwnerChip, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { adoptCatalogProcess, deleteProcess, toggleProcessScope } from "@/server/actions/processes";
|
||||
import { BIA_TONE } from "@/components/bia-popup";
|
||||
|
||||
const BASE = "/onboarding?step=processes";
|
||||
const biaHref = (id: string) => `${BASE}&bia=${id}&biaStep=1`;
|
||||
|
||||
// Bahnen des Prozesshauses (VDA/TISAX-Ordnung: Management oben, Kern in der Mitte,
|
||||
// Unterstützung unten).
|
||||
const LANES = ["MANAGEMENT", "CORE", "SUPPORT"] as const;
|
||||
|
||||
/** Linker Rahmen der Kachel nach biaStatus (Farbkennzeichnung offen/teilweise/komplett). */
|
||||
const BIA_BORDER: Record<string, string> = {
|
||||
offen: "border-l-[rgba(139,147,173,0.55)]",
|
||||
teilweise: "border-l-[var(--warn)]",
|
||||
komplett: "border-l-[var(--ok)]",
|
||||
};
|
||||
|
||||
/**
|
||||
* TISAX v3A — Prozesshaus als Onboarding-Schritt. Bahnen Management / Kern /
|
||||
* Unterstützung; je Prozess eine Kachel mit Aktivierungs-Schalter (inScope), Owner +
|
||||
* Stellvertreter (bzw. „unbesetzt"), Haupt-/Teilprozess-Tiefe (parentId, aufklappbar)
|
||||
* und Farbkennzeichnung nach biaStatus. Klick auf die Kachel öffnet das geführte
|
||||
* BIA-Popup (Informationswert → Träger → Schutzbedarf → Risiken → Abschluss).
|
||||
*/
|
||||
export async function ProcessesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const t = await getTranslations("processHouse");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
const canUse = hasPermission(session, "onboarding:use") && hasPermission(session, "bia:write");
|
||||
|
||||
const [processes, users, catalog] = await Promise.all([
|
||||
db.process.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
catalogCode: true,
|
||||
inScope: true,
|
||||
biaStatus: true,
|
||||
ownerId: true,
|
||||
deputyOwnerId: true,
|
||||
parentId: true,
|
||||
owner: { select: { name: true } },
|
||||
bia: { select: { criticality: true } },
|
||||
processAssets: {
|
||||
select: { role: true, asset: { select: { type: true, confidentiality: true, integrity: true, availability: true } } },
|
||||
},
|
||||
_count: { select: { risks: true } },
|
||||
},
|
||||
}),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.processCatalogEntry.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
]);
|
||||
|
||||
type Node = (typeof processes)[number];
|
||||
const usersById = new Map(users.map((u) => [u.id, u.name]));
|
||||
const childrenByParent = new Map<string, Node[]>();
|
||||
for (const p of processes) {
|
||||
if (p.parentId) {
|
||||
const arr = childrenByParent.get(p.parentId) ?? [];
|
||||
arr.push(p);
|
||||
childrenByParent.set(p.parentId, arr);
|
||||
}
|
||||
}
|
||||
const topLevel = processes.filter((p) => !p.parentId);
|
||||
|
||||
// Katalog-Übernahme: bereits vorhandene Codes/Namen markieren.
|
||||
const takenCodes = new Set(processes.map((p) => p.name));
|
||||
const takenCatalog = new Set(processes.map((p) => p.catalogCode).filter((c): c is string => !!c));
|
||||
|
||||
// Katalog-Prozess-Tiefe: nur Hauptprozesse listen, Teilprozesse (parentCode) je
|
||||
// Hauptprozess zählen — beim „Übernehmen" werden sie automatisch mit angelegt.
|
||||
const catalogTop = catalog.filter((c) => !c.parentCode);
|
||||
const catalogChildCount = new Map<string, number>();
|
||||
for (const c of catalog) {
|
||||
if (c.parentCode) catalogChildCount.set(c.parentCode, (catalogChildCount.get(c.parentCode) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const inScopeCount = processes.filter((p) => p.inScope).length;
|
||||
const doneCount = processes.filter((p) => p.biaStatus === "komplett").length;
|
||||
|
||||
function progressDots(p: Node) {
|
||||
const primaries = p.processAssets.filter(
|
||||
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
|
||||
);
|
||||
const secondaries = p.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
const dots: { label: string; ok: boolean }[] = [
|
||||
{ label: t("dotInfo"), ok: primaries.length > 0 },
|
||||
{ label: t("dotCarrier"), ok: secondaries.length > 0 },
|
||||
{ label: t("dotCia"), ok: primaries.some((pa) => pa.asset.confidentiality > 1 || pa.asset.integrity > 1 || pa.asset.availability > 1) },
|
||||
{ label: t("dotRisk"), ok: p._count.risks > 0 },
|
||||
];
|
||||
return dots;
|
||||
}
|
||||
|
||||
function tile(p: Node, depth: number) {
|
||||
const kids = childrenByParent.get(p.id) ?? [];
|
||||
const deputyName = p.deputyOwnerId ? usersById.get(p.deputyOwnerId) : null;
|
||||
return (
|
||||
<div key={p.id} className={depth > 0 ? "ml-4 border-l pl-3" : ""}>
|
||||
<div
|
||||
className={`rounded-xl border border-l-[3px] bg-card p-3 ${BIA_BORDER[p.biaStatus] ?? BIA_BORDER.offen} ${
|
||||
p.inScope ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={biaHref(p.id)} className="flex-1 font-heading text-[13.5px] font-semibold hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<Pill tone={BIA_TONE[p.biaStatus] ?? "mut"}>{t(`status.${p.biaStatus}`)}</Pill>
|
||||
{p.bia && <CriticalityPill level={p.bia.criticality} label={String(p.bia.criticality)} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]">
|
||||
<OwnerChip name={p.owner?.name} noOwnerLabel={t("unassigned")} />
|
||||
<span className="text-muted-foreground">
|
||||
{t("deputy")}: {deputyName ?? <span className="italic">{t("unassigned")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{progressDots(p).map((d) => (
|
||||
<Pill key={d.label} tone={d.ok ? "ok" : "mut"}>{d.label}</Pill>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" nativeButton={false} render={<Link href={biaHref(p.id)} />}>
|
||||
{t("openBia")} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
{canUse && (
|
||||
<form action={toggleProcessScope.bind(null, p.id)}>
|
||||
<input type="hidden" name="inScope" value={p.inScope ? "" : "on"} />
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{p.inScope ? t("deactivate") : t("activate")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" nativeButton={false} render={<Link href={`${BASE}&detail=${p.id}`} />}>
|
||||
{t("details")}
|
||||
</Button>
|
||||
{canUse && (
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-7 cursor-pointer list-none items-center gap-1 rounded-[min(var(--radius-md),12px)] border border-input px-2.5 text-[0.8rem] font-semibold text-destructive select-none hover:bg-destructive/10 [&::-webkit-details-marker]:hidden">
|
||||
<Trash2 className="size-3.5" /> {t("delete")}
|
||||
</summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-72 rounded-xl border bg-card p-3 text-[12px]">
|
||||
<p className="mb-2.5 text-muted-foreground">{t("deleteConfirm")}</p>
|
||||
<form action={deleteProcess.bind(null, p.id)}>
|
||||
<input type="hidden" name="returnTo" value="house" />
|
||||
<Button type="submit" size="sm" variant="destructive">
|
||||
<Trash2 className="size-3.5" /> {t("deleteConfirmBtn")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kids.length > 0 && (
|
||||
<details className="mt-2" open>
|
||||
<summary className="cursor-pointer text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("subProcesses", { count: kids.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">{kids.map((k) => tile(k, depth + 1))}</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Kopf + Kennzahlen + Anlegen */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold">
|
||||
<Workflow className="size-4" /> {t("title")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{canUse && (
|
||||
<Button size="sm" nativeButton={false} render={<Link href={`${BASE}&new=1`} />}>
|
||||
<Plus className="size-4" /> {t("newProcess")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/processes" />}>
|
||||
{t("toModule")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("intro")}</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: t("kpiTotal"), value: processes.length },
|
||||
{ label: t("kpiInScope"), value: inScopeCount },
|
||||
{ label: t("kpiDone"), value: doneCount },
|
||||
].map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Legende Farbkennzeichnung */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium">{t("legend")}:</span>
|
||||
{(["offen", "teilweise", "komplett"] as const).map((s) => (
|
||||
<span key={s} className="inline-flex items-center gap-1.5">
|
||||
<i className={`inline-block h-3 w-3 rounded-sm border-l-[3px] ${BIA_BORDER[s]} bg-card`} />
|
||||
{t(`status.${s}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bahnen */}
|
||||
{processes.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">{t("empty")}</div>
|
||||
) : (
|
||||
LANES.map((lane) => {
|
||||
const laneTop = topLevel.filter((p) => p.category === lane);
|
||||
return (
|
||||
<div key={lane} className="rounded-xl border bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="font-heading text-sm font-semibold">{tCat(lane)}</span>
|
||||
<Tag>{laneTop.length}</Tag>
|
||||
</div>
|
||||
{laneTop.length === 0 ? (
|
||||
<p className="text-[12px] text-muted-foreground">{t("laneEmpty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">{laneTop.map((p) => tile(p, 0))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Aus Standard-Katalog übernehmen */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">{t("catalogTitle")}</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("catalogHint")}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{catalogTop.map((c) => {
|
||||
const taken = takenCatalog.has(c.code) || takenCodes.has(c.name);
|
||||
const childCount = catalogChildCount.get(c.code) ?? 0;
|
||||
return (
|
||||
<li key={c.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone="mut">{tCat(c.category)}</Pill>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{c.code}</span>
|
||||
{childCount > 0 && (
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">
|
||||
· {t("inclSub", { count: childCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{taken ? (
|
||||
<Pill tone="ok">{t("adopted")}</Pill>
|
||||
) : canUse ? (
|
||||
<form action={adoptCatalogProcess}>
|
||||
<input type="hidden" name="code" value={c.code} />
|
||||
<Button type="submit" size="sm" variant="outline">{t("adopt")}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone="info">{tc("none")}</Pill>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill, CiaBadge } from "@/components/mockup-ui";
|
||||
|
||||
/**
|
||||
* M2 Schritt „Schutzbedarf" (Ebene 2). Der Schutzbedarf C/I/A liegt am Asset.
|
||||
* Primäre Informations-Assets tragen den echten Wert; sekundäre Träger erben per
|
||||
* MAXIMUM der von ihnen (im selben Prozess) getragenen primären Werte
|
||||
* (Kumulation/Verteilung bleiben fachliche Entscheidung im Asset-Modul).
|
||||
*
|
||||
* Dieser Schritt analysiert (read-only) den Bestand: unbewertete primäre Werte und
|
||||
* Träger, deren C/I/A unter dem geerbten Maximum liegt. Pflege erfolgt im Asset-Modul.
|
||||
*/
|
||||
export async function ProtectionStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const processes = await db.process.findMany({
|
||||
select: {
|
||||
processAssets: {
|
||||
select: {
|
||||
role: true,
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Primäre Informations-Werte ohne echte Bewertung (alles auf 1) — Bewertung nachholen.
|
||||
const unratedPrimary = new Map<string, string>();
|
||||
// Träger, deren C/I/A unter dem geerbten Maximum liegt (Vererbung verletzt).
|
||||
const inheritanceGaps: { name: string; have: [number, number, number]; need: [number, number, number] }[] = [];
|
||||
|
||||
for (const p of processes) {
|
||||
const primaries = p.processAssets.filter(
|
||||
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
|
||||
);
|
||||
const maxC = Math.max(1, ...primaries.map((pa) => pa.asset.confidentiality));
|
||||
const maxI = Math.max(1, ...primaries.map((pa) => pa.asset.integrity));
|
||||
const maxA = Math.max(1, ...primaries.map((pa) => pa.asset.availability));
|
||||
|
||||
for (const pa of primaries) {
|
||||
const a = pa.asset;
|
||||
if (a.confidentiality === 1 && a.integrity === 1 && a.availability === 1) {
|
||||
unratedPrimary.set(a.id, a.name);
|
||||
}
|
||||
}
|
||||
if (primaries.length === 0) continue;
|
||||
for (const pa of p.processAssets) {
|
||||
if (pa.role !== "SECONDARY") continue;
|
||||
const a = pa.asset;
|
||||
if (a.confidentiality < maxC || a.integrity < maxI || a.availability < maxA) {
|
||||
inheritanceGaps.push({
|
||||
name: a.name,
|
||||
have: [a.confidentiality, a.integrity, a.availability],
|
||||
need: [maxC, maxI, maxA],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Schutzbedarf C/I/A</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<ShieldCheck className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Der Schutzbedarf liegt am Asset. Primäre Informationswerte tragen den echten Wert; Träger
|
||||
erben per <b>Maximum</b> der getragenen primären Werte. Pflege im Asset-Modul.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{unratedPrimary.size}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">primäre Werte ohne Bewertung</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{inheritanceGaps.length}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Träger unter geerbtem Maximum</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unratedPrimary.size > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Unbewertete primäre Werte</span>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{[...unratedPrimary.values()].map((name) => (
|
||||
<li key={name} className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[12px]">
|
||||
{name} <Pill tone="warn">C/I/A offen</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Vererbung (Maximum-Prinzip)</span>
|
||||
{inheritanceGaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Alle Träger erfüllen mindestens das geerbte Maximum ihrer primären Werte.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{inheritanceGaps.map((g, i) => (
|
||||
<li key={`${g.name}-${i}`} className="flex flex-wrap items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<span className="flex-1 font-medium">{g.name}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
ist <CiaBadge c={g.have[0]} i={g.have[1]} a={g.have[2]} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
soll (min.) <CiaBadge c={g.need[0]} i={g.need[1]} a={g.need[2]} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { createRiskGapTasks } from "@/server/actions/onboarding-steps";
|
||||
import { getSuggestedRisksForProcesses, adoptSuggestedRisk } from "@/server/actions/risk-catalog";
|
||||
|
||||
/**
|
||||
* Risiken-Schritt (Schritt 6). Setzt auf dem bestehenden Risikomodul auf (Register,
|
||||
* Heatmap, Standard-Risikokatalog C4) — keine Doppel-Datenhaltung. Zeigt Register-/
|
||||
* Katalogstand und offene Behandlungslücken und legt dafür Aufgaben an.
|
||||
*/
|
||||
export async function RisksStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [total, high, highUntreated, catalogTotal, adopted, accepted] = await Promise.all([
|
||||
db.risk.count(),
|
||||
db.risk.count({ where: { score: { gte: 10 } } }),
|
||||
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
|
||||
db.riskCatalogEntry.count(),
|
||||
db.risk.count({ where: { catalogCode: { not: null } } }),
|
||||
db.risk.count({ where: { status: "ACCEPTED" } }),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Risiken im Register", value: total },
|
||||
{ label: `Katalog übernommen (von ${catalogTotal})`, value: adopted },
|
||||
{ label: "Hoch-Risiken", value: high },
|
||||
];
|
||||
const gaps = [
|
||||
total === 0 ? { label: "Register leer — Standard-Risikokatalog sichten und relevante Risiken übernehmen", tone: "warn" as const } : null,
|
||||
total > 0 && adopted === 0 && catalogTotal > 0 ? { label: "Standard-Risikokatalog (C4) noch nicht genutzt", tone: "info" as const } : null,
|
||||
highUntreated > 0 ? { label: `${highUntreated} Hoch-Risiko/-Risiken ohne verknüpfte Maßnahme`, tone: "warn" as const } : null,
|
||||
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
|
||||
|
||||
// M2: Standard-Risiken (suggestedRiskCodes) je übernommenem Katalog-Prozess mit
|
||||
// Ein-Klick-Übernahme (adoptSuggestedRisk, ohne den Wizard zu verlassen).
|
||||
const riskSuggestions = await getSuggestedRisksForProcesses();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Risikoanalyse</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks/catalog" />}>Risikokatalog</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks" />}><ShieldAlert className="size-4" /> Zum Risikoregister</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Davon {accepted} akzeptiert. Der Wizard nutzt das bestehende Risikomodul (Register,
|
||||
Heatmap, Katalog-Übernahme, Maßnahmenverknüpfung) — Pflege dort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Behandlungsstand</span>
|
||||
{canUse && (
|
||||
<form action={createRiskGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Risiken erfasst und Hoch-Risiken mit Maßnahmen hinterlegt — Risikostand ist assessment-tauglich.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
|
||||
<span>{g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{riskSuggestions.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Empfohlene Risiken je Prozess (Katalog)</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Standard-Risiken (C4) aus dem Prozess-Katalog. „Übernehmen“ legt das Risiko mit
|
||||
Default-Bewertung im Register an — anschließend dort anpassen.
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{riskSuggestions.map((s) => (
|
||||
<div key={s.processId}>
|
||||
<p className="text-[12.5px] font-medium">{s.processName}</p>
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{s.risks.map((r) => (
|
||||
<li key={r.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone="mut">{r.category}</Pill>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.title}</span>
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{r.code}</span>
|
||||
</span>
|
||||
{r.adopted ? (
|
||||
<Pill tone="ok">übernommen</Pill>
|
||||
) : canUse ? (
|
||||
<form action={adoptSuggestedRisk.bind(null, r.code)}>
|
||||
<Button type="submit" size="sm" variant="outline">Übernehmen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone="info">offen</Pill>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Client-seitiges Wizard-Popup im Stil des serverseitigen `Modal` (src/components/modal.tsx),
|
||||
* aber mit lokalem Open-State statt searchParams — die Schritt-Komponenten des Onboardings
|
||||
* erhalten keine searchParams, daher wird das Öffnen/Schließen hier per State gesteuert.
|
||||
* Optik (Overlay, Karte, Header, Footer) bleibt bewusst identisch zum bestehenden Modal.
|
||||
*/
|
||||
export function Popup({
|
||||
triggerLabel,
|
||||
triggerIcon,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "sm",
|
||||
triggerClassName,
|
||||
title,
|
||||
sub,
|
||||
maxWidthClass = "max-w-2xl",
|
||||
children,
|
||||
}: {
|
||||
triggerLabel: ReactNode;
|
||||
triggerIcon?: ReactNode;
|
||||
triggerVariant?: "default" | "outline" | "secondary" | "ghost" | "link";
|
||||
triggerSize?: "default" | "sm" | "xs" | "lg";
|
||||
triggerClassName?: string;
|
||||
title: string;
|
||||
sub?: string;
|
||||
maxWidthClass?: string;
|
||||
/** Erhält eine `close`-Funktion, damit Formulare nach dem Speichern schließen können. */
|
||||
children: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={triggerVariant}
|
||||
size={triggerSize}
|
||||
className={triggerClassName}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{triggerIcon}
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={`shadow-card my-auto w-full ${maxWidthClass} rounded-2xl border bg-card`}>
|
||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{title}</p>
|
||||
{sub && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Schließen"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
{children(close)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { UserPlus, UserMinus, Mail, CircleSlash, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import {
|
||||
assignFunction,
|
||||
unassignFunction,
|
||||
markFunctionUnfilled,
|
||||
inviteFunctionHolder,
|
||||
} from "@/server/actions/onboarding-team";
|
||||
import { Popup } from "./popup";
|
||||
|
||||
export interface HolderView {
|
||||
id: string;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
invitedEmail: string | null;
|
||||
}
|
||||
|
||||
export interface FunctionCardData {
|
||||
key: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
domain: string | null;
|
||||
multi: boolean;
|
||||
soll: string | null;
|
||||
holders: HolderView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten einer ISMS-Funktion im Popup — Logik wie beim Risiken-Popup, nutzt die
|
||||
* bestehenden M1-Server-Actions (`assignFunction`, `unassignFunction`,
|
||||
* `markFunctionUnfilled`, `inviteFunctionHolder`). Auslöser ist der „Bearbeiten“-Button
|
||||
* auf der Funktionskarte (siehe step.tsx).
|
||||
*/
|
||||
export function FunctionEditDialog({
|
||||
fn,
|
||||
users,
|
||||
canUse,
|
||||
canInvite,
|
||||
}: {
|
||||
fn: FunctionCardData;
|
||||
users: { id: string; name: string; email: string }[];
|
||||
canUse: boolean;
|
||||
canInvite: boolean;
|
||||
}) {
|
||||
const filled = fn.holders.length > 0;
|
||||
const canAssignMore = canUse && (fn.multi || !filled);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
triggerLabel={canUse ? "Bearbeiten" : "Ansehen"}
|
||||
triggerIcon={<Pencil className="size-3.5" />}
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title={fn.label}
|
||||
sub={fn.domain ? `Bereich: ${fn.domain}` : undefined}
|
||||
>
|
||||
{() => (
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<p className="text-[12.5px] leading-relaxed">{fn.desc}</p>
|
||||
{fn.soll && (
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Soll (zentral): <b>{fn.soll}</b>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{filled ? (
|
||||
<Pill tone="ok">besetzt{fn.multi ? ` (${fn.holders.length})` : ""}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">unbesetzt</Pill>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktuelle Halter */}
|
||||
<div>
|
||||
<p className="text-[12.5px] font-medium">Aktuelle Besetzung</p>
|
||||
{filled ? (
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{fn.holders.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border bg-background px-3 py-2 text-[12.5px]"
|
||||
>
|
||||
<span>
|
||||
<b>{h.userName ?? h.invitedEmail ?? "—"}</b>
|
||||
{h.userEmail && (
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">{h.userEmail}</span>
|
||||
)}
|
||||
{h.invitedEmail && !h.userName && (
|
||||
<span className="ml-1 text-[11px] text-[var(--info)]">eingeladen</span>
|
||||
)}
|
||||
</span>
|
||||
{canUse && (
|
||||
<form action={unassignFunction.bind(null, h.id)}>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-7 px-2 text-[11.5px]">
|
||||
<UserMinus className="size-3.5" /> entfernen
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[12px] text-muted-foreground">Noch niemand zugewiesen.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zuweisen / Einladen / unbesetzt lassen */}
|
||||
{canAssignMore && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<form action={assignFunction.bind(null, fn.key)} className="flex items-center gap-1.5">
|
||||
<select
|
||||
name="userId"
|
||||
required
|
||||
defaultValue=""
|
||||
className="h-8 flex-1 rounded-md border border-input bg-background px-2 text-[12.5px]"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Account auswählen …
|
||||
</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||||
<UserPlus className="size-3.5" /> zuweisen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form action={inviteFunctionHolder.bind(null, fn.key)} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="E-Mail einladen"
|
||||
className="h-8 flex-1 text-[12.5px]"
|
||||
disabled={!canInvite}
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8" disabled={!canInvite}>
|
||||
<Mail className="size-3.5" /> einladen
|
||||
</Button>
|
||||
</form>
|
||||
{!canInvite && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!filled && (
|
||||
<form action={markFunctionUnfilled.bind(null, fn.key)}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-[11.5px] text-muted-foreground"
|
||||
>
|
||||
<CircleSlash className="size-3.5" /> unbesetzt lassen (Aufgabe anlegen)
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!canUse && (
|
||||
<p className="border-t pt-3 text-[12px] text-muted-foreground">
|
||||
Nur Lesezugriff — Änderungen erfordern das Recht „Onboarding nutzen“.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Link from "next/link";
|
||||
import { Settings, Users } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { loadRoleContext, loadFunctionAssignments, listAssignableUsers } from "@/server/roles";
|
||||
import { evaluateFt, FT_SEVERITY_LABEL, FT_SEVERITY_TONE } from "@/lib/ft-rules";
|
||||
import { renderIsbBestellung } from "@/lib/isb-bestellung";
|
||||
import { FUNCTION_DEFS, type FunctionDef } from "@/lib/onboarding/functions";
|
||||
import { createFtTasks } from "@/server/actions/onboarding";
|
||||
import { FunctionEditDialog } from "./role-edit-dialog";
|
||||
|
||||
/**
|
||||
* Team / Funktionszuordnung (Ebene 1 „Fundament", M1). Löst die read-only
|
||||
* Rollen-Anzeige durch eine echte Zuweisung Funktion → User(n) ab: bestehende
|
||||
* Accounts zuweisen, per E-Mail einladen (SEC2) oder eine Funktion bewusst
|
||||
* unbesetzt lassen (→ Task „Funktion besetzen"). Ergänzend die Funktionstrennungs-
|
||||
* Prüfung (FT-01…06) und die ISB-Bestellung als Vorlage.
|
||||
*/
|
||||
export async function RolesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
const canInvite = hasPermission(session, "user:manage");
|
||||
|
||||
const [ctx, assignments, users] = await Promise.all([
|
||||
loadRoleContext(db),
|
||||
loadFunctionAssignments(db),
|
||||
listAssignableUsers(db),
|
||||
]);
|
||||
|
||||
const findings = evaluateFt(ctx);
|
||||
const openTasks = findings.filter((f) => f.task).length;
|
||||
const date = new Date().toLocaleDateString("de-DE");
|
||||
const bestellung = renderIsbBestellung({ ISB: ctx.roles.ISB, MANAGEMENT: ctx.roles.MANAGEMENT, ORG_NAME: ctx.orgName, DOC_DATE: date });
|
||||
|
||||
const byFunction = new Map<string, typeof assignments>();
|
||||
for (const a of assignments) {
|
||||
const list = byFunction.get(a.functionKey) ?? [];
|
||||
list.push(a);
|
||||
byFunction.set(a.functionKey, list);
|
||||
}
|
||||
|
||||
// Soll-Benennung aus der korrespondierenden Zentralvariable (Vorschlag/Abgleich).
|
||||
const sollOf = (def: FunctionDef): string | null => {
|
||||
if (!def.roleVar) return null;
|
||||
const key = def.roleVar.replace(/^ROLE_/, "") as keyof typeof ctx.roles;
|
||||
const v = (ctx.roles[key] ?? "").trim();
|
||||
return v || null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Team / Funktionszuordnung */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Team & Funktionszuordnung</span>
|
||||
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
|
||||
<Settings className="size-3" /> Soll-Rollen & Zentralvariablen
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Jede ISMS-Funktion als Karte — Besetzung auf einen Blick. Zum Zuweisen, Einladen oder
|
||||
bewussten Unbesetzt-Lassen (erzeugt eine Aufgabe „Funktion besetzen“) die Karte
|
||||
<b> bearbeiten</b>.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{FUNCTION_DEFS.map((def) => {
|
||||
const holders = byFunction.get(def.key) ?? [];
|
||||
const soll = sollOf(def);
|
||||
const filled = holders.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={def.key}
|
||||
className={`flex flex-col rounded-xl border border-l-[3px] bg-[var(--surface-soft)] p-3.5 ${filled ? "border-l-[var(--ok)]" : "border-l-[var(--warn)]"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[13px] font-semibold">{def.label}</p>
|
||||
{filled ? (
|
||||
<Pill tone="ok">besetzt{def.multi ? ` (${holders.length})` : ""}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">unbesetzt</Pill>
|
||||
)}
|
||||
</div>
|
||||
{def.domain && (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">Bereich: {def.domain}</p>
|
||||
)}
|
||||
<p className="mt-1 line-clamp-2 text-[11.5px] text-muted-foreground">{def.desc}</p>
|
||||
|
||||
<div className="mt-2 min-h-[1.25rem] text-[12px]">
|
||||
{filled ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
{holders.map((h) => (
|
||||
<span key={h.id} className="rounded-md bg-background px-1.5 py-0.5">
|
||||
{h.userName ?? h.invitedEmail ?? "—"}
|
||||
{h.invitedEmail && !h.userName && (
|
||||
<span className="ml-1 text-[10.5px] text-[var(--info)]">eingeladen</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{soll ? (
|
||||
<>Soll (zentral): <b>{soll}</b></>
|
||||
) : (
|
||||
"Noch keine Person zugewiesen."
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end border-t pt-2.5">
|
||||
<FunctionEditDialog
|
||||
fn={{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
desc: def.desc,
|
||||
domain: def.domain,
|
||||
multi: Boolean(def.multi),
|
||||
soll,
|
||||
holders: holders.map((h) => ({
|
||||
id: h.id,
|
||||
userName: h.userName,
|
||||
userEmail: h.userEmail,
|
||||
invitedEmail: h.invitedEmail,
|
||||
})),
|
||||
}}
|
||||
users={users}
|
||||
canUse={canUse}
|
||||
canInvite={canInvite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!canInvite && <p className="mt-2 text-[11px] text-muted-foreground">Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.</p>}
|
||||
</div>
|
||||
|
||||
{/* Funktionstrennung FT-01…06 */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Funktionstrennung (FT-01…06)</span>
|
||||
{canUse && (
|
||||
<form action={createFtTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{openTasks ? ` (${openTasks})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{findings.map((f) => (
|
||||
<li key={f.code} className="flex flex-wrap items-start gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{f.code}</span>
|
||||
<Pill tone={FT_SEVERITY_TONE[f.severity]}>{FT_SEVERITY_LABEL[f.severity]}</Pill>
|
||||
<span className="flex-1">
|
||||
<b>{f.title}</b>
|
||||
{f.control ? <span className="text-muted-foreground"> · Control {f.control}</span> : null}
|
||||
<span className="block text-[11.5px] text-muted-foreground">{f.message}</span>
|
||||
{f.task && <span className="text-[11px] text-[var(--info)]">→ Aufgabe{f.task.reuse ? " (aus Fragebogen)" : ""}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* ISB-Bestellung (inline, aus C7-Baustein) */}
|
||||
<details className="rounded-xl border bg-card p-4">
|
||||
<summary className="cursor-pointer list-none text-[13px] font-semibold [&::-webkit-details-marker]:hidden">ISB-Bestellung anzeigen (Vorlage)</summary>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Erzeugt aus den zentralen Variablen — Vorschau/Kopiervorlage (keine Paket-Vorlage).</p>
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-lg border bg-[var(--surface-soft)] p-3 text-[11.5px] whitespace-pre-wrap">{bestellung}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
|
||||
import { scopeSummary, type Pruefziel } from "@/lib/scope-filter";
|
||||
import { saveScope } from "@/server/actions/onboarding";
|
||||
|
||||
/**
|
||||
* Scoping-Schritt (Story A2). Zeigt das Assessment-Level read-only (A2-1) und erfasst
|
||||
* den Geltungsbereich (Prüfziele, Standorte, Ausschlüsse) als WizardScope (A2-2). Eine
|
||||
* Live-Vorschau zeigt die daraus resultierenden aktiven Anforderungen (Scope-Filter).
|
||||
*/
|
||||
export async function ScopingStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const level = await getAssessmentLevel(db);
|
||||
const veryHigh = level === "AL3";
|
||||
const scopeLabel = veryHigh ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH";
|
||||
|
||||
const scope = await db.wizardScope.findFirst();
|
||||
const pruefziele = (scope?.pruefziele as Pruefziel[]) ?? ["informationssicherheit"];
|
||||
const hasProto = pruefziele.includes("prototypenschutz");
|
||||
const hasDatenschutz = pruefziele.includes("datenschutz");
|
||||
|
||||
// FLAG_INCLUDE_SHOULD (SOLL-Anforderungen) stammt aus dem Fragebogen (Q-GOV-05, Default an).
|
||||
const should = await db.wizardFact.findUnique({ where: { tenantId_key: { tenantId: session.user.tenantId, key: "Q-GOV-05" } } });
|
||||
const includeShould = should ? should.value === true : true;
|
||||
|
||||
const summary = scopeSummary({ pruefziele, flags: { ...protectionFlags(level), FLAG_INCLUDE_SHOULD: includeShould } });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Assessment-Level (read-only, A2-1) */}
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold">Assessment-Level</span>
|
||||
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] text-muted-foreground"><Lock className="size-3" /> read-only</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
Geprüfte Anforderungsstufen: <b>{scopeLabel}</b>. Zentral im Admin-/Superadmin-Portal
|
||||
gesetzt (einzige Quelle des Schutzbedarfs); im Wizard nicht änderbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope erfassen */}
|
||||
<form action={saveScope} className="space-y-4">
|
||||
<fieldset className="rounded-xl border bg-card p-4">
|
||||
<legend className="px-1 text-[13px] font-semibold">Prüfziele</legend>
|
||||
<div className="mt-1 space-y-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-muted-foreground">
|
||||
<input type="checkbox" checked disabled /> Informationssicherheit <span className="text-[11.5px]">(immer aktiv)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="pz_prototypenschutz" defaultChecked={hasProto} /> Prototypenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 8.x)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="pz_datenschutz" defaultChecked={hasDatenschutz} /> Datenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 9.x)</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="geltungsbereich">Geltungsbereich</label>
|
||||
<Input id="geltungsbereich" name="geltungsbereich" defaultValue={scope?.geltungsbereich ?? ""} placeholder="z. B. gesamte Organisation / definierter Bereich" className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="standorte">Standorte <span className="text-[11.5px] text-muted-foreground">(einer je Zeile)</span></label>
|
||||
<Textarea id="standorte" name="standorte" rows={3} defaultValue={(scope?.standorte ?? []).join("\n")} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="ausschluesse">Ausschlüsse</label>
|
||||
<Textarea id="ausschluesse" name="ausschluesse" rows={3} defaultValue={scope?.ausschluesse ?? ""} placeholder="Ausgeschlossene Bereiche/Systeme" className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canUse && <Button type="submit">Scope speichern</Button>}
|
||||
</form>
|
||||
|
||||
{/* Live-Vorschau der aktiven Anforderungen (Scope-Filter) */}
|
||||
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<b>Resultierender Scope</b>
|
||||
<span className="font-heading text-[15px] font-bold text-foreground">{summary.total} Anforderungen</span>
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
über {summary.controls} Controls · MUSS {summary.byType.MUSS} · SOLL {summary.byType.SOLL} · HOCH {summary.byType.HOCH} · SEHR HOCH {summary.byType["SEHR HOCH"]}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Abgeleitet aus Prüfzielen, Assessment-Level ({level}) und SOLL-Einbezug
|
||||
({includeShould ? "an" : "aus"}). Ändert sich der Scope, passen sich die nachgelagerten Schritte an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import Link from "next/link";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Send } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { applyProtection, buildContext, effectiveLevel, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
||||
import { isCentralVariable } from "@/lib/policy-variables";
|
||||
import { POLICY_STATUS_LABEL, POLICY_STATUS_TONE, POLICY_TYPE_LABEL } from "@/components/policy-modals";
|
||||
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
||||
import { submitForApproval, updatePolicyTemplate, updateScopedVariables } from "@/server/actions/policies";
|
||||
|
||||
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
/** Im Dokument vorkommende Variablen (inkl. über BL-Referenzen gebunden), ohne Feature-Flags. §8 */
|
||||
function docVariableKeys(raw: string, baseline: { blId: string; vorgabe: string }[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const m of raw.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)) keys.add(m[1]);
|
||||
const bls = new Set([...raw.matchAll(/BL-[A-Z]+-\d+/g)].map((m) => m[0]));
|
||||
for (const bl of bls) {
|
||||
const p = baseline.find((b) => b.blId === bl);
|
||||
if (p) for (const m of p.vorgabe.matchAll(/\{\{\s*([A-Z][A-Z0-9_]*)\s*\}\}/g)) keys.add(m[1]);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export default async function PolicyEditPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ code: string }>;
|
||||
searchParams: Promise<{ expert?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const t = await getTranslations("policies");
|
||||
const tc = await getTranslations("common");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const { code } = await params;
|
||||
const { expert: expertParam } = await searchParams;
|
||||
const expert = expertParam === "1";
|
||||
const decoded = decodeURIComponent(code);
|
||||
|
||||
if (!hasPermission(session, "policy:write")) redirect(`/policies/${code}`);
|
||||
|
||||
const doc = await db.policyDocument.findFirst({ where: { code: decoded } });
|
||||
if (!doc) notFound();
|
||||
if (!TEMPLATE_TYPES.has(doc.type)) redirect(`/policies/${code}`);
|
||||
|
||||
const [variables, baseline, users, allDocs] = await Promise.all([
|
||||
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.policyBaselineParam.findMany(),
|
||||
db.user.findMany({ select: { id: true, name: true } }),
|
||||
db.policyDocument.findMany({ select: { code: true, title: true }, orderBy: { orderIdx: "asc" } }),
|
||||
]);
|
||||
const userName = (id: string | null) => users.find((u) => u.id === id)?.name ?? id ?? "—";
|
||||
|
||||
// Freigeber-Auswahl: aktive Nutzer mit Freigaberecht (≠ Einreicher).
|
||||
const approvers = await db.user.findMany({
|
||||
where: { status: "ACTIVE", id: { not: session.user.id }, userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } } },
|
||||
select: { id: true, name: true }, orderBy: { name: "asc" },
|
||||
});
|
||||
const openTask = doc.status === "IN_FREIGABE"
|
||||
? await db.task.findFirst({ where: { entityType: "policy_document", entityId: doc.id, status: "OPEN" }, select: { assigneeId: true } })
|
||||
: null;
|
||||
|
||||
const usedKeys = docVariableKeys(doc.rawMarkdown, baseline);
|
||||
// Zentrale Variablen (Organisation/Rollen) sind nur in den Einstellungen pflegbar → hier ausblenden.
|
||||
const docVars = variables.filter((v) => usedKeys.has(v.key) && v.kind !== "boolean" && !isCentralVariable(v));
|
||||
const keysCsv = docVars.map((v) => v.key).join(",");
|
||||
|
||||
// Schutzbedarf wird zentral gesteuert (Superadmin) — kein Override je Richtlinie mehr.
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
|
||||
const effLevel = effectiveLevel(globalVeryHigh);
|
||||
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
|
||||
const stripBaseline = doc.code !== "BASELINE";
|
||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href={`/policies/${code}`} className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("backToDoc")}
|
||||
</Link>
|
||||
|
||||
<div className="mt-3 mb-5 flex flex-wrap items-center gap-2">
|
||||
<h1 className="font-heading text-2xl font-bold">{t("editTitle")}</h1>
|
||||
<Pill tone={POLICY_STATUS_TONE[doc.status]}>{POLICY_STATUS_LABEL[doc.status]}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{doc.code} · {doc.title} · {POLICY_TYPE_LABEL[doc.type]}</span>
|
||||
</div>
|
||||
|
||||
{/* Modus-Umschalter: Standard (Variablen) ↔ Experten (Rohtext) */}
|
||||
<div className="mb-5 inline-flex rounded-lg border bg-card p-1 text-[12.5px] font-semibold">
|
||||
<Link href={`/policies/${code}/edit`} className={!expert ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("modeStandard")}</Link>
|
||||
<Link href={`/policies/${code}/edit?expert=1`} className={expert ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("modeExpert")}</Link>
|
||||
</div>
|
||||
|
||||
{expert ? (
|
||||
<div className="grid gap-5 lg:grid-cols-[1.4fr_1fr]">
|
||||
{/* Rohtext-Editor mit Werkzeugleiste */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">{t("template")}</p>
|
||||
<Button type="submit" form="policy-template" size="sm">{tc("save")}</Button>
|
||||
</div>
|
||||
<PolicyExpertEditor
|
||||
formId="policy-template"
|
||||
saveAction={updatePolicyTemplate.bind(null, decoded)}
|
||||
initialMarkdown={doc.rawMarkdown}
|
||||
variables={variables.map((v) => ({ key: v.key, title: v.title }))}
|
||||
docs={allDocs}
|
||||
/>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("expertHint")}</p>
|
||||
</div>
|
||||
{/* Vorschau (aktualisiert beim Speichern) */}
|
||||
<div>
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{t("preview")}</p>
|
||||
<div className="shadow-card max-h-[80vh] overflow-y-auto rounded-2xl border bg-card p-6">
|
||||
{infoMd && (
|
||||
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
|
||||
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">{t("docInfo")}</summary>
|
||||
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
|
||||
</details>
|
||||
)}
|
||||
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_300px]">
|
||||
{/* Gerendertes Dokument */}
|
||||
<div className="shadow-card rounded-2xl border bg-card p-6">
|
||||
{infoMd && (
|
||||
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
|
||||
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
{t("docInfo")}
|
||||
</summary>
|
||||
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
|
||||
</details>
|
||||
)}
|
||||
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
|
||||
{/* Rechte Leiste: Freigabe + Variablen */}
|
||||
<div className="space-y-4">
|
||||
{/* Freigabe-Workflow (Vier-Augen, §8) — Einreichen erzeugt eine Aufgabe */}
|
||||
<div className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<p className="font-heading text-sm font-semibold">{t("approval")}</p>
|
||||
<div className="mt-3 space-y-2">
|
||||
{(doc.status === "ENTWURF" || doc.status === "FREIGEGEBEN") && (
|
||||
approvers.length === 0 ? (
|
||||
<p className="rounded-lg border border-[var(--band-brd)] bg-[var(--band)] px-3 py-2 text-[12px] text-[var(--band-text)]">
|
||||
Kein anderer Nutzer mit Freigaberecht vorhanden. Bitte zuerst einen Freigeber (Rolle mit „policy:approve“) anlegen.
|
||||
</p>
|
||||
) : (
|
||||
<form action={submitForApproval.bind(null, decoded)} className="space-y-2">
|
||||
{doc.status === "FREIGEGEBEN" && <p className="text-[12px] text-muted-foreground">{t("approvedBy")}: <b>{userName(doc.approvedBy)}</b></p>}
|
||||
<label className="block text-[11.5px] text-muted-foreground">Freigeber wählen</label>
|
||||
<select name="approverId" required defaultValue="" className="h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||
<option value="" disabled>— Person auswählen —</option>
|
||||
{approvers.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<Input name="note" placeholder="Notiz an den Freigeber (optional)" className="h-8" />
|
||||
<Button type="submit" className="w-full justify-center">
|
||||
<Send className="size-4" /> {doc.status === "FREIGEGEBEN" ? t("resubmit") : t("submitForApproval")}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
)}
|
||||
{doc.status === "IN_FREIGABE" && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[12px] text-muted-foreground">{t("submittedBy")}: <b>{userName(doc.submittedBy)}</b></p>
|
||||
<p className="text-[12px] text-muted-foreground">Zur Freigabe bei: <b>{userName(openTask?.assigneeId ?? null)}</b></p>
|
||||
<Link href="/tasks" className="inline-block text-[12px] font-semibold text-[var(--primary)] hover:underline">Zur Aufgabe →</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">Freigabe/Ablehnung erfolgen im Bereich „Aufgaben“ durch den gewählten Freigeber (Vier-Augen).</p>
|
||||
</div>
|
||||
|
||||
{/* Schutzbedarf / TISAX-Level — zentral gesteuert (nur Anzeige) */}
|
||||
<div className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<p className="font-heading text-sm font-semibold">{t("protectionLevel")}</p>
|
||||
<p className="mt-1 text-[12.5px]">{t("effectiveLevel")}: <b>{effLevel}</b></p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">Der Schutzbedarf wird zentral vom Plattform-Betreiber gesteuert und ist hier nicht änderbar.</p>
|
||||
</div>
|
||||
|
||||
{/* Dokument-Variablen (§8 Variablen-Scoping) — zentrale Variablen ausgenommen */}
|
||||
<form action={updateScopedVariables.bind(null, decoded)} className="shadow-card rounded-2xl border bg-card p-4">
|
||||
<input type="hidden" name="keys" value={keysCsv} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-heading text-sm font-semibold">{t("docVariables")}</p>
|
||||
<Pill tone="info">geteilt</Pill>
|
||||
</div>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">
|
||||
Werte gelten zentral für <b>alle Richtlinien</b> (eine Pflegestelle). Zentrale Variablen
|
||||
(Unternehmensdaten, Rollen) werden ausschließlich in den <Link href="/settings" className="font-semibold text-[var(--primary)] hover:underline">Einstellungen</Link> gepflegt.
|
||||
</p>
|
||||
<div className="space-y-2.5">
|
||||
{docVars.length === 0 && <p className="text-[12.5px] text-muted-foreground">{tc("none")}</p>}
|
||||
{docVars.map((v) => (
|
||||
<div key={v.key}>
|
||||
<label htmlFor={`var_${v.key}`} className="block text-[11.5px] text-muted-foreground" title={v.key}>{v.title}</label>
|
||||
<Input id={`var_${v.key}`} name={`var_${v.key}`} defaultValue={v.value} className="mt-0.5 h-8 text-[12.5px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{docVars.length > 0 && <Button type="submit" size="sm" className="mt-4 w-full justify-center">{t("saveVariables")}</Button>}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, Pencil } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import {
|
||||
PolicyReadView,
|
||||
POLICY_STATUS_LABEL,
|
||||
POLICY_STATUS_TONE,
|
||||
POLICY_TYPE_LABEL,
|
||||
} from "@/components/policy-modals";
|
||||
import { PolicyRegisterView } from "@/components/policy-registers";
|
||||
import { GenericRegisterView, type RegisterColumn } from "@/components/generic-register";
|
||||
|
||||
const REGISTER_CODES = new Set(["CRYPTO", "RISKMATRIX", "CLASSIFICATION", "HANDBUCH"]);
|
||||
// Aus Vorlagen-Markdown gerenderte Dokumente (bearbeitbar über den Editor)
|
||||
const TEMPLATE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
export default async function PolicyDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ code: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const t = await getTranslations("policies");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const { code } = await params;
|
||||
|
||||
const doc = await db.policyDocument.findFirst({ where: { code: decodeURIComponent(code) } });
|
||||
if (!doc) notFound();
|
||||
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const isRegister = REGISTER_CODES.has(doc.code);
|
||||
// Generisches verwaltetes Register (WP3.0): editierbare Zeilen nach Pflichtspalten.
|
||||
const genericReg = doc.type === "REGISTER" && !isRegister
|
||||
? await db.managedRegister.findFirst({ where: { code: doc.code } })
|
||||
: null;
|
||||
|
||||
let content: React.ReactNode;
|
||||
if (genericReg) {
|
||||
const [rows, allAssets] = await Promise.all([
|
||||
db.registerRow.findMany({ where: { registerId: genericReg.id }, orderBy: { orderIdx: "asc" } }),
|
||||
db.asset.findMany({ select: { id: true, name: true, type: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
content = (
|
||||
<GenericRegisterView
|
||||
register={{
|
||||
code: genericReg.code, title: genericReg.title, description: genericReg.description,
|
||||
columns: (genericReg.columns as unknown as RegisterColumn[]) ?? [],
|
||||
supplierLink: genericReg.supplierLink, assetLink: genericReg.assetLink,
|
||||
}}
|
||||
rows={rows.map((r) => ({ id: r.id, values: (r.values as Record<string, string>) ?? {}, supplierRef: r.supplierRef, assetRef: r.assetRef }))}
|
||||
canWrite={canWrite}
|
||||
suppliers={allAssets.filter((a) => a.type === "SUPPLIER" || a.type === "IT_SERVICE").map((a) => ({ id: a.id, name: a.name }))}
|
||||
assets={allAssets.map((a) => ({ id: a.id, name: a.name }))}
|
||||
/>
|
||||
);
|
||||
} else if (isRegister) {
|
||||
const data =
|
||||
doc.code === "CRYPTO"
|
||||
? { crypto: await db.cryptoEntry.findMany({ orderBy: { orderIdx: "asc" } }) }
|
||||
: doc.code === "CLASSIFICATION"
|
||||
? {
|
||||
classes: await db.classificationClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
aspects: await db.handlingAspect.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
rules: await db.handlingRule.findMany(),
|
||||
}
|
||||
: doc.code === "RISKMATRIX"
|
||||
? {
|
||||
riskClasses: await db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
ewLevels: await db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||
damage: await db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
}
|
||||
: { handbook: await db.handbookTopic.findMany({ orderBy: { orderIdx: "asc" } }), variables: await db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }) };
|
||||
content = <PolicyRegisterView doc={doc} data={data} canWrite={canWrite} />;
|
||||
} else {
|
||||
const [variables, reqs] = await Promise.all([
|
||||
db.policyVariable.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.policyRequirement.findMany({ where: { policyCode: doc.code, archivedAt: null } }),
|
||||
]);
|
||||
const controls = [...new Set(reqs.map((r) => r.control))].sort();
|
||||
const relatedVas = [...new Set(reqs.flatMap((r) => r.vaCodes))].sort();
|
||||
content = <PolicyReadView data={{ doc, variables, controls, relatedVas }} />;
|
||||
}
|
||||
|
||||
const showEdit = canWrite && TEMPLATE_TYPES.has(doc.type);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/policies" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("backToLibrary")}
|
||||
</Link>
|
||||
|
||||
<div className="mt-3 mb-5 flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="font-heading text-2xl font-bold">{doc.code} · {doc.title}</h1>
|
||||
<Pill tone={POLICY_STATUS_TONE[doc.status]}>{POLICY_STATUS_LABEL[doc.status]}</Pill>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">
|
||||
{POLICY_TYPE_LABEL[doc.type]} · v{doc.version}
|
||||
{(isRegister || genericReg) && canWrite ? ` · ${t("editableInline")}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{showEdit && (
|
||||
<Button nativeButton={false} render={<Link href={`/policies/${doc.code}/edit`} />}>
|
||||
<Pencil className="size-4" /> {t("edit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shadow-card rounded-2xl border bg-card p-6">{content}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { AlertTriangle, CheckCircle2, History, ArrowLeft } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { setDocReviewCycle, markDocReviewed, acknowledgePolicy } from "@/server/actions/policy-control";
|
||||
import { REVIEW_CYCLE_KEYS, isReviewDue } from "@/lib/review-cycle";
|
||||
|
||||
/**
|
||||
* Dokumentenlenkung (AP5 · A.5.1 / Klausel 7.3 / A.6.3). Übersicht „Prüfung fällig",
|
||||
* Prüfzyklus je Dokument, dokumentierte Neuversion mit Historie und Lesebestätigung je
|
||||
* Version. Modul „policies"; Verwaltung mit policy:write, Bestätigen mit policy:read.
|
||||
*/
|
||||
const inp = "h-8 rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
const fmt = (d: Date | null) => (d ? new Date(d).toLocaleDateString("de-DE") : "—");
|
||||
const REVIEWABLE = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
export default async function PolicyControlPage() {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "policy:read")) redirect("/dashboard");
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const me = session.user.id;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [docsRaw, acks, userCount] = await Promise.all([
|
||||
db.policyDocument.findMany({
|
||||
where: { archivedAt: null },
|
||||
orderBy: { orderIdx: "asc" },
|
||||
include: { versionHistory: { orderBy: { createdAt: "desc" } } },
|
||||
}),
|
||||
db.policyAcknowledgement.findMany({ select: { policyDocumentId: true, version: true, userId: true } }),
|
||||
db.user.count({ where: { status: "ACTIVE" } }),
|
||||
]);
|
||||
const docs = docsRaw.filter((d) => REVIEWABLE.has(d.type));
|
||||
|
||||
const ackFor = (docId: string, version: string) => acks.filter((a) => a.policyDocumentId === docId && a.version === version);
|
||||
const meAcked = (docId: string, version: string) => acks.some((a) => a.policyDocumentId === docId && a.version === version && a.userId === me);
|
||||
const due = docs.filter((d) => isReviewDue(d.nextReviewAt));
|
||||
|
||||
return (
|
||||
<main className="flex-1 space-y-4 p-6">
|
||||
<Link href="/policies" className="inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground"><ArrowLeft className="size-3.5" /> Bibliothek</Link>
|
||||
<PageHead crumb="Richtlinien" title="Dokumentenlenkung" sub="Prüfzyklen & fällige Überprüfungen, Neuversionen mit Historie und Lesebestätigungen je Version." />
|
||||
|
||||
{/* Prüfung fällig */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 flex items-center gap-2 font-heading text-sm font-semibold"><AlertTriangle className="size-4 text-amber-600" />Prüfung fällig ({due.length})</p>
|
||||
{due.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">Keine überfälligen Überprüfungen.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2 text-[12.5px]">
|
||||
{due.map((d) => <li key={d.id}><Pill tone="warn">{d.code} · fällig {fmt(d.nextReviewAt)}</Pill></li>)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Dokumente */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Dokumente ({docs.length})</p>
|
||||
<div className="space-y-1.5">
|
||||
{docs.map((d) => {
|
||||
const ackCount = ackFor(d.id, d.version).length;
|
||||
const iAck = meAcked(d.id, d.version);
|
||||
return (
|
||||
<details key={d.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-mono text-[12px]">{d.code}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{d.title}</span>
|
||||
<Pill tone="info">v{d.version}</Pill>
|
||||
{d.reviewCycle
|
||||
? <Pill tone={isReviewDue(d.nextReviewAt) ? "warn" : "mut"}>{d.reviewCycle} · nächste {fmt(d.nextReviewAt)}</Pill>
|
||||
: <Pill tone="mut">kein Zyklus</Pill>}
|
||||
<Pill tone={ackCount >= userCount && userCount > 0 ? "ok" : "mut"}>{ackCount}/{userCount} gelesen</Pill>
|
||||
</summary>
|
||||
|
||||
<div className="space-y-3 border-t p-3 text-[12.5px]">
|
||||
{/* Lesebestätigung (jeder mit policy:read) */}
|
||||
<form action={acknowledgePolicy.bind(null, d.id)} className="flex items-center gap-2">
|
||||
{iAck
|
||||
? <Pill tone="ok"><CheckCircle2 className="mr-1 size-3" />Von Ihnen bestätigt (v{d.version})</Pill>
|
||||
: <Button type="submit" size="sm">Als gelesen bestätigen (v{d.version})</Button>}
|
||||
</form>
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-t pt-3">
|
||||
<form action={setDocReviewCycle.bind(null, d.id)} className="flex items-end gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Prüfzyklus
|
||||
<select name="reviewCycle" defaultValue={d.reviewCycle ?? ""} className={`${inp} ml-1`}>
|
||||
<option value="">— keiner —</option>
|
||||
{REVIEW_CYCLE_KEYS.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<Button type="submit" variant="outline" size="sm">Zyklus setzen</Button>
|
||||
</form>
|
||||
<form action={markDocReviewed.bind(null, d.id)} className="flex items-end gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Änderungshinweis<input name="changeNote" placeholder="Turnusmäßige Überprüfung" className={`${inp} ml-1 w-56`} /></label>
|
||||
<Button type="submit" size="sm">Als geprüft markieren (neue Version)</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Historie */}
|
||||
<div className="border-t pt-3">
|
||||
<p className="mb-1 flex items-center gap-1.5 text-[12px] font-semibold"><History className="size-3.5" />Versionshistorie</p>
|
||||
{d.versionHistory.length === 0 ? (
|
||||
<p className="text-muted-foreground">Nur die aktuelle Version v{d.version} — noch keine frühere protokolliert.</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
<li className="text-muted-foreground">Aktuell: <span className="font-mono">v{d.version}</span></li>
|
||||
{d.versionHistory.map((h) => (
|
||||
<li key={h.id}><span className="font-mono">v{h.version}</span> · {fmt(h.createdAt)} · {h.changeNote ?? "—"}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import Link from "next/link";
|
||||
import { ListChecks, Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission, hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { applyProtection, buildContext } from "@/lib/policy-render";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import type { ImplementationHint } from "@prisma/client";
|
||||
import { createHintTask } from "@/server/actions/hints";
|
||||
|
||||
const STUFE_TONE: Record<string, "info" | "warn" | "ok" | "risk" | "mut"> = {
|
||||
MUSS: "risk", SOLL: "info", HOCH: "warn", "SEHR HOCH": "risk",
|
||||
};
|
||||
|
||||
function HintCard({ hint, canWrite }: { hint: ImplementationHint; canWrite: boolean }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-heading text-[13px] font-semibold">
|
||||
{hint.reqId} <span className="font-normal text-muted-foreground">— {hint.requirement}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Pill tone={STUFE_TONE[hint.stufe] ?? "mut"}>{hint.stufe}</Pill>
|
||||
{hint.procurement && <Pill tone="warn">Beschaffung</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
<dl className="mt-2 grid gap-1 text-[12px] sm:grid-cols-2">
|
||||
<div><dt className="inline font-medium">Organisatorisch: </dt><dd className="inline text-muted-foreground">{hint.organisational}</dd></div>
|
||||
<div><dt className="inline font-medium">Technisch: </dt><dd className="inline text-muted-foreground">{hint.technical}</dd></div>
|
||||
<div><dt className="inline font-medium">Nachweise: </dt><dd className="inline text-muted-foreground">{hint.evidence}</dd></div>
|
||||
<div><dt className="inline font-medium">Ressourcen: </dt><dd className="inline text-muted-foreground">{hint.resources}</dd></div>
|
||||
</dl>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
{hint.template && <span>Vorlage: {hint.template}</span>}
|
||||
<span>· AL-Filter: {hint.alFilter}</span>
|
||||
{canWrite && hint.procurement && (
|
||||
<form action={createHintTask.bind(null, hint.reqId)} className="ml-auto">
|
||||
<Button type="submit" size="sm" variant="outline"><Plus className="size-3.5" /> Aufgabe anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Umsetzungshinweise (Story B5-2). Kontextsensitiv: nur Hinweise zu den im Scope
|
||||
* aktiven Anforderungen des Mandanten (condition-Filter wie Coverage) und passend
|
||||
* zum Assessment-Level (AL). Beschaffungsbedarf → Aufgabe (F1).
|
||||
*/
|
||||
export default async function HintsPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [settings, variables, reqs, hints] = await Promise.all([
|
||||
db.tenantSettings.findFirst(),
|
||||
db.policyVariable.findMany(),
|
||||
db.policyRequirement.findMany({ where: { archivedAt: null } }),
|
||||
db.implementationHint.findMany({ orderBy: { reqId: "asc" } }),
|
||||
]);
|
||||
const al = settings?.tisaxLevel === "AL3" ? "AL3" : "AL2";
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const activeReqIds = new Set(reqs.filter((r) => !r.condition || ctx[r.condition] === true).map((r) => r.reqId));
|
||||
|
||||
// Kontextsensitiv: nur aktive Anforderungen + passender AL-Filter.
|
||||
const relevant = hints.filter((h) => activeReqIds.has(h.reqId) && h.alFilter.includes(al));
|
||||
const byControl = new Map<string, ImplementationHint[]>();
|
||||
for (const h of relevant) {
|
||||
const list = byControl.get(h.control) ?? [];
|
||||
list.push(h);
|
||||
byControl.set(h.control, list);
|
||||
}
|
||||
const controls = [...byControl.keys()].sort(compareControl);
|
||||
const procurementCount = relevant.filter((h) => h.procurement).length;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Richtlinien"
|
||||
title="Umsetzungshinweise"
|
||||
sub={`${relevant.length} Hinweise im Scope (${al}) · ${procurementCount} mit Beschaffungsbedarf`}
|
||||
actions={<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies" />}>Zur Bibliothek</Button>}
|
||||
/>
|
||||
|
||||
{relevant.length === 0 ? (
|
||||
<p className="mt-4 text-sm text-muted-foreground">Keine Hinweise im aktuellen Scope. Ggf. Fragebogen/Scoping ausfüllen oder Vorlagen importieren.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-6">
|
||||
{controls.map((control) => (
|
||||
<section key={control}>
|
||||
<h2 className="mb-2 flex items-center gap-1.5 font-heading text-sm font-semibold">
|
||||
<ListChecks className="size-4 text-muted-foreground" /> {control} — {controlTitle(control)}
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{byControl.get(control)!.map((h) => <HintCard key={h.reqId} hint={h} canWrite={canWrite} />)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("policies");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission, hasPermission } from "@/server/rbac";
|
||||
import { importPolicyPackage } from "@/server/actions/policy-package";
|
||||
import { PageHead, Pill, Tag, KpiCard } from "@/components/mockup-ui";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import { PolicyDomainView } from "@/components/policy-domain-view";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { controlTitle, compareControl } from "@/lib/control-titles";
|
||||
import { applyProtection, buildContext, renderPolicyMarkdown } from "@/lib/policy-render";
|
||||
import type { PolicyRequirement } from "@prisma/client";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const DOC_TYPES = ["LEITLINIE", "RICHTLINIE", "VERFAHREN", "REGISTER", "HANDBUCH"] as const;
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie",
|
||||
RICHTLINIE: "Richtlinie",
|
||||
VERFAHREN: "Verfahren",
|
||||
REGISTER: "Register",
|
||||
HANDBUCH: "Handbuch",
|
||||
EIGENES: "Eigenes",
|
||||
};
|
||||
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
||||
FREIGEGEBEN: "ok",
|
||||
IN_FREIGABE: "info",
|
||||
ENTWURF: "warn",
|
||||
ARCHIVIERT: "mut",
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
FREIGEGEBEN: "Freigegeben",
|
||||
IN_FREIGABE: "In Freigabe",
|
||||
ENTWURF: "Entwurf",
|
||||
ARCHIVIERT: "Archiviert",
|
||||
};
|
||||
|
||||
const OBLIGATION_STYLE: Record<string, string> = {
|
||||
MUSS: "bg-[rgba(214,60,94,0.18)] text-[#e88aa0]",
|
||||
SOLL: "bg-[rgba(90,169,230,0.18)] text-[var(--info)]",
|
||||
HOCH: "bg-[rgba(226,128,46,0.2)] text-[#e2a06e]",
|
||||
"SEHR HOCH": "bg-[rgba(125,111,214,0.22)] text-[#c3bdec]",
|
||||
};
|
||||
function ObligationBadge({ type }: { type: string }) {
|
||||
return (
|
||||
<span className={`inline-block whitespace-nowrap rounded px-1.5 py-0.5 text-[10px] font-bold ${OBLIGATION_STYLE[type] ?? "bg-muted text-muted-foreground"}`}>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Schutzbedarf-/TISAX-Level — zentral vom Betreiber gesteuert (nur Anzeige). */
|
||||
function TisaxLevelCard({ level }: { level: "AL2" | "AL3" }) {
|
||||
return (
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="text-[12px] text-muted-foreground">Schutzbedarf / TISAX-Level</p>
|
||||
<p className="mt-1.5 font-heading text-lg font-bold">{level}</p>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">{level === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"} · zentral gesteuert</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gruppenblock „nach Dokument": Kopfzeile je Richtlinie + Anforderungszeilen. */
|
||||
function PolicyGroup({
|
||||
policy,
|
||||
title,
|
||||
rows,
|
||||
clean,
|
||||
}: {
|
||||
policy: string;
|
||||
title: string;
|
||||
rows: PolicyRequirement[];
|
||||
clean: (s: string, n?: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b bg-[var(--surface-soft)]">
|
||||
<td colSpan={4} className="p-2.5">
|
||||
<Link href={`/policies/${policy}`} className="font-heading font-bold hover:underline">{title}</Link>{" "}
|
||||
<span className="text-[11px] text-muted-foreground">{policy}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b align-top last:border-0">
|
||||
<td className="p-3"><b className="font-heading">{r.control}</b></td>
|
||||
<td className="p-3"><ObligationBadge type={r.obligation} /> <span title={clean(r.requirement, 400)}>{clean(r.requirement, 110)}…</span></td>
|
||||
<td className="p-3 text-muted-foreground">{clean(r.implementation, 130)}…</td>
|
||||
<td className="p-3">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{r.vaCodes.length === 0 ? <span className="text-muted-foreground">–</span> : [...r.vaCodes].sort().map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`}><Pill tone="info">{va}</Pill></Link>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function PoliciesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ type?: string; q?: string; doc?: string; view?: string; dir?: string; import?: string; derived?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const t = await getTranslations("policies");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const params = await searchParams;
|
||||
|
||||
const [docs, requirements, variables] = await Promise.all([
|
||||
// Aktive Bibliothek/Coverage: deaktivierte (nicht mehr im Paket enthaltene)
|
||||
// Dokumente/Anforderungen ausblenden — Historie bleibt per Direktlink erreichbar.
|
||||
db.policyDocument.findMany({ where: { archivedAt: null }, orderBy: { orderIdx: "asc" } }),
|
||||
db.policyRequirement.findMany({ where: { archivedAt: null }, orderBy: [{ control: "asc" }, { reqId: "asc" }] }),
|
||||
db.policyVariable.findMany(),
|
||||
]);
|
||||
const titleOf = (code: string) => docs.find((d) => d.code === code)?.title ?? code;
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
// Coverage nach Assessment-Level: nur Anforderungen, deren Bedingung im effektiven
|
||||
// Level erfüllt ist (AL2 blendet „sehr hoch" = FLAG_VERY_HIGH_PROTECTION aus).
|
||||
const activeRequirements = requirements.filter((r) => !r.condition || ctx[r.condition] === true);
|
||||
const clean = (s: string, n = 160) =>
|
||||
renderPolicyMarkdown(s, ctx, { readMode: true, stripBaseline: true })
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, n);
|
||||
|
||||
// Ableitung: je Richtlinie ihre Controls + operationalisierende Verfahren
|
||||
const byPolicy = new Map<string, { controls: Set<string>; vas: Set<string> }>();
|
||||
for (const r of requirements) {
|
||||
if (!byPolicy.has(r.policyCode)) byPolicy.set(r.policyCode, { controls: new Set(), vas: new Set() });
|
||||
const e = byPolicy.get(r.policyCode)!;
|
||||
e.controls.add(r.control);
|
||||
r.vaCodes.forEach((v) => e.vas.add(v));
|
||||
}
|
||||
|
||||
const isCoverage = params.view === "coverage";
|
||||
const isDomains = params.view === "domains";
|
||||
|
||||
const head = (
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterTabs
|
||||
tabs={[
|
||||
{ href: "/policies", label: t("library"), active: !isCoverage && !isDomains },
|
||||
{ href: "/policies?view=domains", label: t("byDomain"), active: isDomains },
|
||||
{ href: "/policies?view=coverage", label: t("coverage"), active: isCoverage },
|
||||
]}
|
||||
/>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/hints" />}>Umsetzungshinweise</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/control" />}>Dokumentenlenkung</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/updates" />}>Paket-Updates</Button>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/upload" />}>Eigene Richtlinie hochladen</Button>
|
||||
<form action={importPolicyPackage}>
|
||||
<Button type="submit" variant="outline" size="sm">Vorlagen importieren/aktualisieren</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Nicht-destruktiver Import-Report (Story B4-1) als Banner: Query „import=d{a}-{u}-{a}-{r}_r{a}-{u}-{a}".
|
||||
const importParam = params.import;
|
||||
const importBanner = importParam
|
||||
? (() => {
|
||||
const m = /^d(\d+)-(\d+)-(\d+)-(\d+)_r(\d+)-(\d+)-(\d+)$/.exec(importParam);
|
||||
if (!m) return null;
|
||||
const [, da, du, dar, dre, ra, ru, rar] = m.map(Number) as unknown as number[];
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-[var(--ok)]/40 bg-[var(--ok)]/5 p-3 text-[12.5px]">
|
||||
<b>Vorlagen importiert/aktualisiert.</b>{" "}
|
||||
Dokumente: {da} neu · {du} aktualisiert · {dar} archiviert · {dre} reaktiviert.{" "}
|
||||
Anforderungen: {ra} neu · {ru} aktualisiert · {rar} archiviert. Status/Freigabe/Overrides und
|
||||
gepflegte Variablenwerte bleiben erhalten.
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
|
||||
/* ── Nach Fachbereich (Domain) ── */
|
||||
if (isDomains) {
|
||||
const derived = params.derived;
|
||||
const derivedBanner = derived != null
|
||||
? (() => {
|
||||
const n = Number(derived);
|
||||
return (
|
||||
<div className="mb-4 rounded-xl border border-[var(--ok)]/40 bg-[var(--ok)]/5 p-3 text-[12.5px]">
|
||||
{n > 0 ? t("deriveDomainsDone", { n }) : t("deriveDomainsNone")}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
{derivedBanner}
|
||||
<div className="mt-3">
|
||||
{/* Fachbereichs-Ansicht als geteilte Komponente (auch im Onboarding-Schritt genutzt). */}
|
||||
<PolicyDomainView db={db} currentUserId={session.user.id} canWrite={canWrite} />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Referenz-/Coverage-Matrix (§9.3, nach Mockup) ── */
|
||||
if (isCoverage) {
|
||||
const dir = params.dir === "doc" ? "doc" : "ctrl";
|
||||
const controlCount = new Set(activeRequirements.map((r) => r.control)).size;
|
||||
const vasOf = (ids: string[]) => [...new Set(ids)].sort();
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("coverageHint", { controls: controlCount, reqs: activeRequirements.length })}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex rounded-lg border bg-card p-1 text-[12px] font-semibold">
|
||||
<Link href="/policies?view=coverage" className={dir === "ctrl" ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("byControl")}</Link>
|
||||
<Link href="/policies?view=coverage&dir=doc" className={dir === "doc" ? "rounded-md bg-[var(--sidebar-accent)] px-3 py-1.5 text-[var(--primary)]" : "px-3 py-1.5 text-muted-foreground hover:text-foreground"}>{t("byDocument")}</Link>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" disabled title={t("comingSoon")}>{t("assessmentExport")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-3 overflow-x-auto rounded-xl border bg-card">
|
||||
{dir === "ctrl" ? (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-3 text-left">{t("control")}</th>
|
||||
<th className="p-3 text-left">{t("requirements")}</th>
|
||||
<th className="p-3 text-left">{t("policy")}</th>
|
||||
<th className="p-3 text-left">{t("procedures")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(activeRequirements.map((r) => r.control))].sort(compareControl).map((control) => {
|
||||
const list = activeRequirements.filter((r) => r.control === control);
|
||||
const vas = vasOf(list.flatMap((r) => r.vaCodes));
|
||||
return (
|
||||
<tr key={control} className="border-b align-top last:border-0">
|
||||
<td className="p-3">
|
||||
<b className="font-heading">{control}</b>
|
||||
<div className="text-[11px] text-muted-foreground">{controlTitle(control)}</div>
|
||||
</td>
|
||||
<td className="p-3">
|
||||
<ul className="space-y-1.5">
|
||||
{list.map((r) => (
|
||||
<li key={r.id}>
|
||||
<ObligationBadge type={r.obligation} /> <span title={clean(r.requirement, 400)}>{clean(r.requirement, 120)}…</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
<td className="p-3"><Link href={`/policies/${list[0].policyCode}`} className="font-semibold hover:underline">{list[0].policyCode}</Link></td>
|
||||
<td className="p-3">
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{vas.length === 0 ? <span className="text-muted-foreground">–</span> : vas.map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`}><Pill tone="info">{va}</Pill></Link>
|
||||
))}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-3 text-left">{t("control")}</th>
|
||||
<th className="p-3 text-left">{t("requirement")}</th>
|
||||
<th className="p-3 text-left">{t("implementation")}</th>
|
||||
<th className="p-3 text-left">{t("procedures")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{[...new Set(activeRequirements.map((r) => r.policyCode))].map((policy) => (
|
||||
<PolicyGroup key={policy} policy={policy} title={titleOf(policy)} rows={activeRequirements.filter((r) => r.policyCode === policy)} clean={clean} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Bibliothek ── */
|
||||
const activeType = DOC_TYPES.includes(params.type as (typeof DOC_TYPES)[number]) ? params.type : null;
|
||||
const q = params.q?.toLowerCase();
|
||||
const filtered = docs.filter(
|
||||
(d) =>
|
||||
(!activeType || d.type === activeType) &&
|
||||
(!q || d.title.toLowerCase().includes(q) || d.code.toLowerCase().includes(q))
|
||||
);
|
||||
|
||||
const kpiControls = new Set(requirements.map((r) => r.control)).size;
|
||||
const count = (typ: string) => requirements.filter((r) => r.obligation === typ).length;
|
||||
const globalVeryHigh = variables.find((v) => v.key === "FLAG_VERY_HIGH_PROTECTION")?.value === "true";
|
||||
const globalLevel = globalVeryHigh ? "AL3" : "AL2";
|
||||
|
||||
const typeHref = (v: string | null) => `/policies${v ? `?type=${v}` : ""}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
{importBanner}
|
||||
|
||||
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<KpiCard label={t("kpiDocs")} value={docs.length} trend={t("kpiDocsTrend")} />
|
||||
<KpiCard label={t("kpiControls")} value={kpiControls} trend={t("kpiControlsTrend")} />
|
||||
<KpiCard label={t("kpiRequirements")} value={requirements.length} trend={`${count("MUSS")} MUSS · ${count("SOLL")} SOLL · ${count("HOCH")} HOCH · ${count("SEHR HOCH")} SEHR HOCH`} />
|
||||
<TisaxLevelCard level={globalLevel} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<FilterTabs
|
||||
tabs={[
|
||||
{ href: typeHref(null), label: t("all"), active: !activeType },
|
||||
...DOC_TYPES.map((v) => ({ href: typeHref(v), label: TYPE_LABEL[v], active: activeType === v })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("code")}</TableHead>
|
||||
<TableHead>{t("docTitle")}</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("version")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
<TableHead>{t("coverageCol")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{filtered.map((d) => {
|
||||
const cov = byPolicy.get(d.code);
|
||||
return (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="text-muted-foreground">{d.code}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/policies/${d.code}`} className="font-bold hover:underline">{d.title}</Link>
|
||||
</TableCell>
|
||||
<TableCell><Tag>{TYPE_LABEL[d.type]}</Tag></TableCell>
|
||||
<TableCell className="text-muted-foreground">v{d.version}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[d.status]}>{STATUS_LABEL[d.status]}</Pill></TableCell>
|
||||
<TableCell className="text-[12px] text-muted-foreground">
|
||||
{d.type === "RICHTLINIE" && cov ? t("controlsN", { n: cov.controls.size })
|
||||
: d.type === "VERFAHREN" && d.policyCode ? `→ ${d.policyCode}`
|
||||
: "–"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { join } from "node:path";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { prisma, dbForTenant } from "@/server/db";
|
||||
import { requirePermission, hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { reconcilePackage } from "../../../../../prisma/import-policies";
|
||||
import { resolvePackageForTenant, getTenantPrimaryFramework } from "../../../../../prisma/template-store";
|
||||
import { importPolicyPackage } from "@/server/actions/policy-package";
|
||||
|
||||
const SEED_DIR = join(process.cwd(), "seed", "isms-vorlagenpaket-v2");
|
||||
|
||||
type Counts = { added: number; updated: number; archived?: number; reactivated?: number };
|
||||
const sum = (c: Counts) => c.added + c.updated + (c.archived ?? 0) + (c.reactivated ?? 0);
|
||||
|
||||
function DiffRow({ label, c }: { label: string; c: Counts }) {
|
||||
const total = sum(c);
|
||||
return (
|
||||
<tr className={total > 0 ? "" : "text-muted-foreground"}>
|
||||
<td className="py-1.5 pr-4 font-medium">{label}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{c.added}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{c.updated}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{c.archived ?? "—"}</td>
|
||||
<td className="py-1.5 pr-3 text-right">{c.reactivated ?? "—"}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Paket-Updates (Story B6). Vergleicht die aktuelle Vorlagenpaket-Version mit der
|
||||
* zuletzt vom Mandanten übernommenen Version und zeigt den Diff (Dry-Run, kein
|
||||
* Schreibzugriff) zur **kontrollierten Übernahme** — nichts wird still überschrieben.
|
||||
*/
|
||||
export default async function PolicyUpdatesPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:read");
|
||||
const canWrite = hasPermission(session, "policy:write");
|
||||
const tenantId = session.user.tenantId;
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
// AP1: Vorschau auf das Primär-Framework des Mandanten (bei TISAX-Mandanten unverändert).
|
||||
const framework = await getTenantPrimaryFramework(prisma, tenantId);
|
||||
// Quelle: veröffentlichte DB-Vorlage in der Sprache des Mandanten (DE-/Datei-Fallback).
|
||||
const { pkg } = await resolvePackageForTenant(prisma, tenantId, SEED_DIR, framework);
|
||||
const currentVersion = pkg.version;
|
||||
const [state, preview, publishedInfo] = await Promise.all([
|
||||
db.policyPackageState.findFirst({ where: { framework } }),
|
||||
reconcilePackage(prisma, tenantId, pkg, { dryRun: true, framework }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }], select: { notes: true, publishedAt: true } }),
|
||||
]);
|
||||
const rep = preview.report;
|
||||
const pending =
|
||||
sum(rep.documents) > 0 || sum(rep.requirements) > 0 ||
|
||||
rep.variables.added + rep.variables.updated > 0 || rep.baseline.added + rep.baseline.updated > 0;
|
||||
const versionMismatch = state?.importedVersion !== currentVersion;
|
||||
const updateAvailable = pending || versionMismatch;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Richtlinien"
|
||||
title="Paket-Updates"
|
||||
sub="Kontrollierte Übernahme von Vorlagen-Aktualisierungen — nichts wird still überschrieben."
|
||||
actions={<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies" />}>Zur Bibliothek</Button>}
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
<div className="shadow-card h-fit rounded-xl border bg-card p-4 text-[13px]">
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Version</p>
|
||||
<dl className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Aktuelles Paket</dt><dd className="font-medium">{currentVersion}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Übernommen</dt><dd className="font-medium">{state?.importedVersion ?? "—"}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Zuletzt am</dt><dd>{state?.importedAt ? new Date(state.importedAt).toLocaleDateString("de-DE") : "—"}</dd></div>
|
||||
</dl>
|
||||
<div className="mt-3">
|
||||
{updateAvailable
|
||||
? <Pill tone="warn"><AlertTriangle className="mr-1 inline size-3.5" />Update verfügbar</Pill>
|
||||
: <Pill tone="ok"><CheckCircle2 className="mr-1 inline size-3.5" />Aktuell</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
{updateAvailable && publishedInfo?.notes && (
|
||||
<div className="mb-3 rounded-lg border border-[var(--warn)]/30 bg-[var(--warn)]/5 p-3 text-[12.5px]">
|
||||
<p className="mb-1 font-heading font-semibold">Änderungshinweis zu Version {currentVersion}</p>
|
||||
<p className="whitespace-pre-wrap text-muted-foreground">{publishedInfo.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Änderungen (Vorschau)</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b"><th className="py-1.5 pr-4 text-left font-medium">Objekt</th><th className="py-1.5 pr-3 text-right font-medium">neu</th><th className="py-1.5 pr-3 text-right font-medium">akt.</th><th className="py-1.5 pr-3 text-right font-medium">arch.</th><th className="py-1.5 pr-3 text-right font-medium">reakt.</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
<DiffRow label="Dokumente" c={rep.documents} />
|
||||
<DiffRow label="Anforderungen" c={rep.requirements} />
|
||||
<DiffRow label="Variablen" c={rep.variables} />
|
||||
<DiffRow label="Baseline" c={rep.baseline} />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Übernahme ist nicht-destruktiv: Status/Freigabe/Overrides und gepflegte Variablenwerte bleiben erhalten; entfernte Einträge werden deaktiviert, nicht gelöscht.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 border-t pt-3">
|
||||
{canWrite && updateAvailable ? (
|
||||
<form action={importPolicyPackage}>
|
||||
<Button type="submit">Übernehmen (Version {currentVersion})</Button>
|
||||
</form>
|
||||
) : !updateAvailable ? (
|
||||
<span className="text-[12.5px] text-muted-foreground">Keine Änderungen zu übernehmen.</span>
|
||||
) : (
|
||||
<span className="text-[12.5px] text-muted-foreground">Nur mit Schreibrecht übernehmbar.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import Link from "next/link";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
||||
import { uploadOwnPolicy } from "@/server/actions/policy-upload";
|
||||
|
||||
/**
|
||||
* Eigene Richtlinie hochladen (Story B4-2). Pflicht: Titel + mind. ein Control.
|
||||
* Datei optional (Storage-Stub). Control-Zuordnung erzeugt Coverage-/Nachweis-Einträge.
|
||||
*/
|
||||
export default async function UploadPolicyPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "policy:write");
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Richtlinien" title="Eigene Richtlinie hochladen" sub="Eigenes Dokument mit Pflicht-Control-Zuordnung ablegen (Datei optional, Storage folgt in Epic S1)." />
|
||||
|
||||
<form action={uploadOwnPolicy} className="mt-4 grid max-w-3xl gap-4">
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">Titel <span className="text-[var(--risk)]">*</span></span>
|
||||
<Input name="title" required placeholder="z. B. Interne Cloud-Nutzungsrichtlinie" className="mt-1" />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">Datei (optional)</span>
|
||||
<input type="file" name="file" className="mt-1 block w-full text-sm file:mr-3 file:rounded-md file:border file:bg-muted file:px-3 file:py-1.5 file:text-sm" />
|
||||
<span className="mt-0.5 block text-[11.5px] text-muted-foreground">Wird über den gekapselten Storage-Adapter abgelegt (Stub: nur Metadaten).</span>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">Control-Zuordnung <span className="text-[var(--risk)]">*</span></span>
|
||||
<span className="mt-0.5 block text-[11.5px] text-muted-foreground">Mehrfachauswahl (Strg/Cmd). Pflicht — bestimmt die Nachweislage/Coverage.</span>
|
||||
<select name="controls" multiple size={12} required className="mt-1 w-full rounded-md border bg-background p-2 text-sm">
|
||||
{ISA_CONTROLS.map((c) => (
|
||||
<option key={c.ref} value={c.ref}>{c.ref} — {c.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">Anforderungs-IDs (optional)</span>
|
||||
<Input name="reqIds" placeholder="z. B. 5.3.2-M1, 6.1.1-S1 (kommagetrennt)" className="mt-1" />
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit">Hochladen & zuordnen</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/policies" />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
redirect(`/processes?edit=${id}`);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Detail/Bearbeiten laufen jetzt als Popup über die Listen-Seite.
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
redirect(`/processes?detail=${id}`);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("bia");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
// Alte Route — Anlegen läuft jetzt als Popup über die Listen-Seite.
|
||||
export default function Page() {
|
||||
redirect("/processes?new=1");
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Network, Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
CriticalityPill,
|
||||
KpiCard,
|
||||
OwnerChip,
|
||||
PageHead,
|
||||
Pill,
|
||||
SectTitle,
|
||||
Tag,
|
||||
} from "@/components/mockup-ui";
|
||||
import { BIA_TONE } from "@/components/bia-popup";
|
||||
import {
|
||||
ProcessCreateModal,
|
||||
ProcessDetailModal,
|
||||
ProcessEditModal,
|
||||
} from "@/components/process-modals";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const PROCESS_INCLUDE = {
|
||||
owner: { select: { name: true } },
|
||||
parent: { select: { name: true } },
|
||||
bia: true,
|
||||
processAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
owner: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dependsOn: { select: { id: true, note: true, target: { select: { id: true, name: true } } } },
|
||||
requiredBy: { select: { id: true, note: true, source: { select: { id: true, name: true } } } },
|
||||
} as const;
|
||||
|
||||
// Bahnen des Prozesshauses (VDA/TISAX-Ordnung: Management oben, Kern in der Mitte,
|
||||
// Unterstützung unten) — identisch zum Onboarding-Prozesshaus.
|
||||
const LANES = ["MANAGEMENT", "CORE", "SUPPORT"] as const;
|
||||
|
||||
/** Linker Rahmen der Kachel nach biaStatus (offen/teilweise/komplett). */
|
||||
const BIA_BORDER: Record<string, string> = {
|
||||
offen: "border-l-[rgba(139,147,173,0.55)]",
|
||||
teilweise: "border-l-[var(--warn)]",
|
||||
komplett: "border-l-[var(--ok)]",
|
||||
};
|
||||
|
||||
/** Statuspunkt (Teilprozesse + Legende). */
|
||||
const BIA_DOT: Record<string, string> = {
|
||||
offen: "bg-[rgba(139,147,173,0.6)]",
|
||||
teilweise: "bg-[var(--warn)]",
|
||||
komplett: "bg-[var(--ok)]",
|
||||
};
|
||||
|
||||
export default async function ProcessesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ detail?: string; edit?: string; new?: string; view?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "bia:read");
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tHouse = await getTranslations("processHouse");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const isTable = params.view === "tabelle";
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
|
||||
// `include` liefert alle Skalarfelder des Prozesses (category, biaStatus, parentId,
|
||||
// interfaces, inScope) mit — daher genügt eine Abfrage für Tabelle und Prozesshaus.
|
||||
const processes = await db.process.findMany({
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: { select: { criticality: true, rtoHours: true, rpoHours: true, mtdHours: true } },
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
type Proc = (typeof processes)[number];
|
||||
|
||||
// Haupt-/Teilprozess-Hierarchie aufbauen.
|
||||
const childrenByParent = new Map<string, Proc[]>();
|
||||
for (const p of processes) {
|
||||
if (p.parentId) {
|
||||
const arr = childrenByParent.get(p.parentId) ?? [];
|
||||
arr.push(p);
|
||||
childrenByParent.set(p.parentId, arr);
|
||||
}
|
||||
}
|
||||
const topLevel = processes.filter((p) => !p.parentId);
|
||||
|
||||
// Strukturierte Prozess-Abhängigkeiten (nur für die Prozesshaus-Ansicht laden).
|
||||
const deps = isTable
|
||||
? []
|
||||
: await db.processDependency.findMany({
|
||||
select: {
|
||||
sourceProcessId: true,
|
||||
targetProcessId: true,
|
||||
note: true,
|
||||
target: { select: { name: true } },
|
||||
source: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
const dependsOnBySource = new Map<string, { name: string; note: string | null }[]>();
|
||||
const requiredByTarget = new Map<string, string[]>();
|
||||
for (const d of deps) {
|
||||
const a = dependsOnBySource.get(d.sourceProcessId) ?? [];
|
||||
a.push({ name: d.target.name, note: d.note });
|
||||
dependsOnBySource.set(d.sourceProcessId, a);
|
||||
const b = requiredByTarget.get(d.targetProcessId) ?? [];
|
||||
b.push(d.source.name);
|
||||
requiredByTarget.set(d.targetProcessId, b);
|
||||
}
|
||||
|
||||
// Kennzahlen.
|
||||
const inScopeCount = processes.filter((p) => p.inScope).length;
|
||||
const biaDoneCount = processes.filter((p) => p.biaStatus === "komplett").length;
|
||||
const criticalCount = processes.filter((p) => (p.bia?.criticality ?? 0) >= 3).length;
|
||||
|
||||
// Roll-up eines Hauptprozesses aus sich + Teilprozessen: Kritikalität = Maximum,
|
||||
// RTO/RPO/MTD = schärfster (kleinster) Wert.
|
||||
const rollup = (p: Proc) => {
|
||||
const kids = childrenByParent.get(p.id) ?? [];
|
||||
const bias = [p, ...kids].map((x) => x.bia).filter((b): b is NonNullable<Proc["bia"]> => !!b);
|
||||
const pick = (sel: (b: NonNullable<Proc["bia"]>) => number | null, agg: "min" | "max") => {
|
||||
const vals = bias.map(sel).filter((v): v is number => v != null);
|
||||
if (!vals.length) return null;
|
||||
return agg === "min" ? Math.min(...vals) : Math.max(...vals);
|
||||
};
|
||||
return {
|
||||
crit: pick((b) => b.criticality, "max"),
|
||||
rto: pick((b) => b.rtoHours, "min"),
|
||||
rpo: pick((b) => b.rpoHours, "min"),
|
||||
mtd: pick((b) => b.mtdHours, "min"),
|
||||
};
|
||||
};
|
||||
|
||||
const fmtH = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||
|
||||
const Metric = ({ k, v }: { k: string; v: number | null | undefined }) => (
|
||||
<div className="min-w-[54px] rounded-lg border bg-[var(--surface-soft)] px-2.5 py-1 text-center">
|
||||
<div className="text-[9px] font-bold tracking-[0.04em] text-muted-foreground uppercase">{k}</div>
|
||||
<div className="font-heading text-[13px] font-semibold tabular-nums">{fmtH(v)}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const interfacesRow = (p: Proc) =>
|
||||
p.interfaces ? (
|
||||
<div className="flex flex-wrap items-center gap-2 px-4 pb-3 pl-9 text-[11.5px]">
|
||||
<span className="font-medium text-muted-foreground">{t("interfaces")}:</span>
|
||||
<span className="inline-flex items-center rounded-full border bg-card px-2.5 py-0.5 font-medium text-foreground/80">
|
||||
{p.interfaces}
|
||||
</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// Abhängigkeits-Chips: „↳ benötigt: …" und „▲ N Prozesse hängen hiervon ab".
|
||||
const depsRow = (p: Proc) => {
|
||||
const req = dependsOnBySource.get(p.id) ?? [];
|
||||
const rby = requiredByTarget.get(p.id) ?? [];
|
||||
if (req.length === 0 && rby.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 px-4 pb-3 pl-9 text-[11.5px]">
|
||||
{req.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-muted-foreground">↳ {t("depsRequires")}:</span>
|
||||
{req.map((d, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center rounded-full border border-dashed bg-card px-2.5 py-0.5 font-medium text-[var(--primary)]"
|
||||
>
|
||||
{d.name}
|
||||
{d.note ? ` · ${d.note}` : ""}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rby.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-medium text-muted-foreground">▲ {t("depsRequiredByN", { count: rby.length })}:</span>
|
||||
{rby.map((n, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center rounded-full border bg-card px-2.5 py-0.5 font-medium text-muted-foreground"
|
||||
>
|
||||
{n}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const mainHeader = (p: Proc, withChevron: boolean) => {
|
||||
const r = rollup(p);
|
||||
const kids = childrenByParent.get(p.id) ?? [];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 p-3.5">
|
||||
{withChevron && (
|
||||
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-open:rotate-90" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
href={`/processes?detail=${p.id}`}
|
||||
className="font-heading text-[14px] font-semibold hover:underline"
|
||||
>
|
||||
{p.name}
|
||||
</Link>
|
||||
<Tag>{tCat(p.category)}</Tag>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px] text-muted-foreground">
|
||||
<OwnerChip name={p.owner?.name} noOwnerLabel={tc("none")} />
|
||||
{kids.length > 0 && <span>· {t("subProcs", { count: kids.length })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5" title={t("rollupHint")}>
|
||||
<Metric k="RTO" v={r.rto} />
|
||||
<Metric k="RPO" v={r.rpo} />
|
||||
<Metric k="MTD" v={r.mtd} />
|
||||
</div>
|
||||
{r.crit != null ? (
|
||||
<CriticalityPill level={r.crit} label={tCrit(String(r.crit))} />
|
||||
) : (
|
||||
<Pill tone="mut">{t("noBia")}</Pill>
|
||||
)}
|
||||
<Pill tone={BIA_TONE[p.biaStatus] ?? "mut"}>{tHouse(`status.${p.biaStatus}`)}</Pill>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const subRow = (c: Proc) => (
|
||||
<div key={c.id} className="flex flex-wrap items-center gap-3 rounded-lg border bg-card px-3 py-2">
|
||||
<span className={`size-2.5 shrink-0 rounded-full ${BIA_DOT[c.biaStatus] ?? BIA_DOT.offen}`} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link href={`/processes?detail=${c.id}`} className="text-[13px] font-semibold hover:underline">
|
||||
{c.name}
|
||||
</Link>
|
||||
<div className="mt-0.5 text-[11.5px] text-muted-foreground">{c.owner?.name ?? tc("none")}</div>
|
||||
{(dependsOnBySource.get(c.id)?.length ?? 0) > 0 && (
|
||||
<div className="mt-0.5 text-[11px] text-[var(--primary)]">
|
||||
↳ {t("depsRequires")}: {(dependsOnBySource.get(c.id) ?? []).map((d) => d.name).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Metric k="RTO" v={c.bia?.rtoHours} />
|
||||
<Metric k="RPO" v={c.bia?.rpoHours} />
|
||||
<Metric k="MTD" v={c.bia?.mtdHours} />
|
||||
</div>
|
||||
{c.bia ? (
|
||||
<CriticalityPill level={c.bia.criticality} label={tCrit(String(c.bia.criticality))} />
|
||||
) : (
|
||||
<Link href={`/processes?detail=${c.id}`} className="text-[11.5px] font-medium text-[var(--primary)] hover:underline">
|
||||
{t("biaOpen")} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mainCard = (p: Proc) => {
|
||||
const kids = childrenByParent.get(p.id) ?? [];
|
||||
const border = `border-l-[3px] ${BIA_BORDER[p.biaStatus] ?? BIA_BORDER.offen}`;
|
||||
if (kids.length === 0) {
|
||||
return (
|
||||
<div key={p.id} className={`rounded-xl border bg-card ${border}`}>
|
||||
{mainHeader(p, false)}
|
||||
{interfacesRow(p)}
|
||||
{depsRow(p)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<details key={p.id} open className={`group rounded-xl border bg-card ${border}`}>
|
||||
<summary className="cursor-pointer list-none rounded-t-xl select-none hover:bg-[var(--surface-soft)] [&::-webkit-details-marker]:hidden">
|
||||
{mainHeader(p, true)}
|
||||
</summary>
|
||||
{interfacesRow(p)}
|
||||
{depsRow(p)}
|
||||
<div className="border-t border-dashed px-4 pt-2 pb-4">
|
||||
<p className="mb-2 pl-6 text-[10.5px] font-bold tracking-[0.04em] text-muted-foreground uppercase">
|
||||
{t("subHeading")}
|
||||
</p>
|
||||
<div className="ml-3 space-y-2 border-l border-dashed pl-5">{kids.map(subRow)}</div>
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
};
|
||||
|
||||
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalProcess = modalId
|
||||
? await db.process.findUnique({ where: { id: modalId }, include: PROCESS_INCLUDE })
|
||||
: null;
|
||||
|
||||
const users =
|
||||
canWrite && (params.edit || params.new)
|
||||
? await db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
// Zugeordnete Risiken: direkt am Prozess ODER an einem seiner Assets
|
||||
const modalRisks = modalProcess
|
||||
? await db.risk.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ processId: modalProcess.id },
|
||||
{ riskAssets: { some: { assetId: { in: modalProcess.processAssets.map((pa) => pa.assetId) } } } },
|
||||
],
|
||||
},
|
||||
select: { id: true, refNo: true, title: true, score: true },
|
||||
orderBy: { score: "desc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
const availableAssets =
|
||||
canWrite && params.edit && modalProcess
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: modalProcess.processAssets.map((pa) => pa.assetId) } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Auswahl möglicher Hauptprozesse (Haupt-/Teilprozess-Hierarchie) — für Anlegen/Bearbeiten.
|
||||
const processOptions =
|
||||
canWrite && (params.edit || params.new) ? processes.map((p) => ({ id: p.id, name: p.name })) : [];
|
||||
|
||||
// Stellvertreter-Name auflösen (deputyOwnerId hat keine Prisma-Relation).
|
||||
const deputy =
|
||||
modalProcess?.deputyOwnerId
|
||||
? await db.user.findUnique({ where: { id: modalProcess.deputyOwnerId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||
|
||||
const toggleCls = (active: boolean) =>
|
||||
`rounded-md px-3 py-1.5 text-[12.5px] font-semibold transition-colors ${
|
||||
active ? "bg-[var(--sidebar-accent)] text-[var(--primary)]" : "text-muted-foreground hover:text-foreground"
|
||||
}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={tAssets("crumb")}
|
||||
title={t("biaTitle")}
|
||||
sub={t("biaSub")}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex rounded-lg border bg-card p-0.5">
|
||||
<Link href="/processes" className={toggleCls(!isTable)}>
|
||||
{t("viewHouse")}
|
||||
</Link>
|
||||
<Link href="/processes?view=tabelle" className={toggleCls(isTable)}>
|
||||
{t("viewTable")}
|
||||
</Link>
|
||||
</div>
|
||||
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||
{t("biaReport")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/processes?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newProcess")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{isTable ? (
|
||||
/* ---------- Tabellen-Ansicht (klassisch) ---------- */
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<div className="p-4 pb-0">
|
||||
<SectTitle title={t("critTable")} />
|
||||
</div>
|
||||
<Table className="mt-2">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("process")}</TableHead>
|
||||
<TableHead>{t("category")}</TableHead>
|
||||
<TableHead>{t("owner")}</TableHead>
|
||||
<TableHead>RTO</TableHead>
|
||||
<TableHead>RPO</TableHead>
|
||||
<TableHead>MTD</TableHead>
|
||||
<TableHead>{t("criticality")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{processes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{processes.map((process) => (
|
||||
<TableRow key={process.id}>
|
||||
<TableCell>
|
||||
<Link href={`/processes?detail=${process.id}`} className="font-bold hover:underline">
|
||||
{process.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag>{tCat(process.category)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{process.owner?.name ?? tc("none")}
|
||||
</TableCell>
|
||||
<TableCell>{hours(process.bia?.rtoHours)}</TableCell>
|
||||
<TableCell>{hours(process.bia?.rpoHours)}</TableCell>
|
||||
<TableCell>{hours(process.bia?.mtdHours)}</TableCell>
|
||||
<TableCell>
|
||||
{process.bia ? (
|
||||
<CriticalityPill
|
||||
level={process.bia.criticality}
|
||||
label={tCrit(String(process.bia.criticality))}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t("noBia")}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
/* ---------- Prozesshaus-Ansicht (Standard) ---------- */
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard label={t("kpiTotal")} value={processes.length} />
|
||||
<KpiCard label={t("kpiInScope")} value={`${inScopeCount} / ${processes.length}`} />
|
||||
<KpiCard label={t("kpiBiaDone")} value={`${biaDoneCount} / ${processes.length}`} />
|
||||
<KpiCard
|
||||
label={t("kpiCritical")}
|
||||
value={criticalCount}
|
||||
trendColor={criticalCount > 0 ? "risk" : "muted"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Legende BIA-Status + Link zum Abhängigkeits-Graph */}
|
||||
<div className="flex flex-wrap items-center gap-3 px-1 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium">{t("biaStatusLegend")}:</span>
|
||||
{(["offen", "teilweise", "komplett"] as const).map((s) => (
|
||||
<span key={s} className="inline-flex items-center gap-1.5">
|
||||
<i className={`size-2.5 rounded-full ${BIA_DOT[s]}`} /> {tHouse(`status.${s}`)}
|
||||
</span>
|
||||
))}
|
||||
<Link
|
||||
href="/dependencies"
|
||||
className="ml-auto inline-flex items-center gap-1 font-semibold text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Network className="size-3.5" /> {t("depsGraphLink")} →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card p-8 text-center text-[12.5px] text-muted-foreground">
|
||||
{t("empty")}
|
||||
</div>
|
||||
) : (
|
||||
LANES.map((lane) => {
|
||||
const laneTop = topLevel.filter((p) => p.category === lane);
|
||||
const laneSub = laneTop.reduce(
|
||||
(n, p) => n + (childrenByParent.get(p.id)?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div key={lane} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<span className="font-heading text-[14px] font-semibold">{tCat(lane)}</span>
|
||||
<Tag>{t("mainProcs", { count: laneTop.length })}</Tag>
|
||||
{laneSub > 0 && (
|
||||
<span className="text-[11.5px] text-muted-foreground">
|
||||
· {t("subProcs", { count: laneSub })}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 h-px flex-1 bg-[var(--border)]" />
|
||||
</div>
|
||||
{laneTop.length === 0 ? (
|
||||
<p className="text-[12px] text-muted-foreground">{t("laneEmpty")}</p>
|
||||
) : (
|
||||
<div className="space-y-2.5">{laneTop.map(mainCard)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalProcess && params.edit && canWrite ? (
|
||||
<ProcessEditModal
|
||||
process={modalProcess}
|
||||
users={users}
|
||||
availableAssets={availableAssets}
|
||||
processes={processOptions}
|
||||
/>
|
||||
) : modalProcess ? (
|
||||
<ProcessDetailModal
|
||||
process={modalProcess}
|
||||
risks={modalRisks}
|
||||
canWrite={canWrite}
|
||||
deputyName={deputy?.name ?? null}
|
||||
/>
|
||||
) : params.new && canWrite ? (
|
||||
<ProcessCreateModal users={users} processes={processOptions} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { Plus, Gauge, ClipboardList, AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
createKpi, recordKpiValue,
|
||||
createManagementReview, updateManagementReview, addReviewDecision, setReviewDecisionStatus,
|
||||
createNonconformity, addCorrectiveAction, updateCorrectiveAction, updateNonconformity,
|
||||
} from "@/server/actions/review";
|
||||
|
||||
/**
|
||||
* Managementklauseln (AP4 · ISO/IEC 27001 9.1, 9.3, 10.2). Kennzahlen mit Zielwerten
|
||||
* und Messwerten je Periode, Managementbewertung entlang der 9.3.2-Agenda und
|
||||
* Nichtkonformitäten mit Korrekturmaßnahme inkl. dokumentierter Wirksamkeitsprüfung.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
const ta = "w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]";
|
||||
const AGENDA_9_3_2 = "Status Vormaßnahmen · Änderungen (intern/extern) · Rückmeldungen zur Leistung (Nichtkonformitäten, Kennzahlen, Auditergebnisse, Zielerreichung) · Rückmeldungen interessierter Parteien · Risikobewertungsergebnisse & Risikobehandlungsplan · Verbesserungsmöglichkeiten";
|
||||
const fmtDate = (d: Date | null) => (d ? new Date(d).toLocaleDateString("de-DE") : "—");
|
||||
|
||||
export default async function ReviewPage() {
|
||||
const session = await requireSession();
|
||||
const canRead = hasPermission(session, "review:manage") || hasPermission(session, "report:read");
|
||||
if (!canRead) redirect("/dashboard");
|
||||
const canWrite = hasPermission(session, "review:manage");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [kpis, reviews, ncs, users] = await Promise.all([
|
||||
db.kpi.findMany({ orderBy: { createdAt: "asc" }, include: { values: { orderBy: { period: "desc" }, take: 6 } } }),
|
||||
db.managementReview.findMany({ orderBy: { reviewDate: "desc" }, include: { decisions: true } }),
|
||||
db.nonconformity.findMany({ orderBy: { detectedAt: "desc" }, include: { actions: true } }),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
const ownerSel = (name: string, val?: string | null) => (
|
||||
<select name={name} defaultValue={val ?? ""} className={inp}><option value="">—</option>{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}</select>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 space-y-6 p-6">
|
||||
<PageHead crumb="ISO 27001" title="Management-Review & Kennzahlen" sub="9.1 Kennzahlen · 9.3 Managementbewertung · 10.2 Korrekturmaßnahmen — die drei Managementklauseln." />
|
||||
|
||||
{/* ── 9.1 Kennzahlen ─────────────────────────────────────────────── */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><Gauge className="size-4 text-[var(--primary)]" />9.1 Kennzahlen</p>
|
||||
{kpis.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Kennzahlen.</p>}
|
||||
<div className="space-y-1.5">
|
||||
{kpis.map((k) => (
|
||||
<details key={k.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-semibold">{k.name}</span>
|
||||
{k.target && <Pill tone="info">Ziel: {k.target}{k.unit ? ` ${k.unit}` : ""}</Pill>}
|
||||
<span className="text-muted-foreground">{k.cadence}</span>
|
||||
<span className="ml-auto flex flex-wrap gap-1">
|
||||
{k.values.map((v) => <Pill key={v.id} tone="mut">{v.period}: {v.value}</Pill>)}
|
||||
</span>
|
||||
</summary>
|
||||
<div className="border-t p-3 text-[12.5px]">
|
||||
{k.description && <p className="mb-2 text-muted-foreground">{k.description}</p>}
|
||||
{k.dataSource && <p className="mb-2"><span className="text-muted-foreground">Datenquelle:</span> {k.dataSource}</p>}
|
||||
{canWrite && (
|
||||
<form action={recordKpiValue.bind(null, k.id)} className="flex flex-wrap items-end gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Periode<input name="period" required placeholder="2026-Q1" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Wert<input name="value" required placeholder="97%" className={inp} /></label>
|
||||
<label className="flex-1 text-[11.5px] text-muted-foreground">Notiz<input name="note" className={inp} /></label>
|
||||
<Button type="submit" size="sm">Messwert erfassen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<form action={createKpi} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<label className="text-[11.5px] text-muted-foreground">Name<input name="name" required placeholder="Offene Maßnahmen überfällig" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Zielwert<input name="target" placeholder="< 5%" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Einheit<input name="unit" placeholder="%" className={`${inp} w-20`} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Turnus
|
||||
<select name="cadence" className={inp}><option>monatlich</option><option>quartalsweise</option><option>jährlich</option></select>
|
||||
</label>
|
||||
<label className="flex-1 text-[11.5px] text-muted-foreground">Datenquelle<input name="dataSource" placeholder="Aufgaben-Modul / Vorfall-SLA" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
|
||||
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Kennzahl</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 9.3 Managementbewertung ────────────────────────────────────── */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><ClipboardList className="size-4 text-[var(--primary)]" />9.3 Managementbewertung</p>
|
||||
{reviews.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Managementbewertung.</p>}
|
||||
<div className="space-y-1.5">
|
||||
{reviews.map((r) => (
|
||||
<details key={r.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-semibold">{fmtDate(r.reviewDate)}</span>
|
||||
<Pill tone={r.status === "abgeschlossen" ? "ok" : "warn"}>{r.status}</Pill>
|
||||
<span className="ml-auto text-muted-foreground">{r.decisions.length} Beschluss/-e ({r.decisions.filter((d) => d.status === "offen").length} offen)</span>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t p-3">
|
||||
{canWrite ? (
|
||||
<form action={updateManagementReview.bind(null, r.id)} className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Datum<input type="date" name="reviewDate" defaultValue={new Date(r.reviewDate).toISOString().slice(0, 10)} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={r.status} className={inp}><option value="entwurf">entwurf</option><option value="abgeschlossen">abgeschlossen</option></select></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Leitung{ownerSel("ownerId", r.ownerId)}</label>
|
||||
</div>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Eingaben (9.3.2)<textarea name="inputs" defaultValue={r.inputs ?? ""} rows={4} placeholder={AGENDA_9_3_2} className={ta} /></label>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Ergebnisse (9.3.3)<textarea name="results" defaultValue={r.results ?? ""} rows={3} className={ta} /></label>
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="text-[12.5px]"><p className="whitespace-pre-wrap"><span className="text-muted-foreground">Eingaben:</span> {r.inputs || "—"}</p><p className="mt-1 whitespace-pre-wrap"><span className="text-muted-foreground">Ergebnisse:</span> {r.results || "—"}</p></div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<p className="mb-1 text-[12px] font-semibold">Beschlüsse (mit Verantwortlichem & Termin)</p>
|
||||
<ul className="space-y-1">
|
||||
{r.decisions.map((d) => (
|
||||
<li key={d.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<Pill tone={d.status === "erledigt" ? "ok" : "warn"}>{d.status}</Pill>
|
||||
<span className="flex-1">{d.decision}</span>
|
||||
<span className="text-muted-foreground">Fällig: {fmtDate(d.dueDate)}</span>
|
||||
{canWrite && (
|
||||
<form action={setReviewDecisionStatus.bind(null, d.id, d.status !== "erledigt")}>
|
||||
<Button type="submit" variant="outline" size="sm">{d.status === "erledigt" ? "wieder offen" : "erledigt"}</Button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canWrite && (
|
||||
<form action={addReviewDecision.bind(null, r.id)} className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<label className="flex-1 text-[11.5px] text-muted-foreground">Beschluss<input name="decision" required className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Termin<input type="date" name="dueDate" className={inp} /></label>
|
||||
<Button type="submit" size="sm">Beschluss</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<form action={createManagementReview} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<label className="text-[11.5px] text-muted-foreground">Datum<input type="date" name="reviewDate" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Leitung{ownerSel("ownerId")}</label>
|
||||
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Managementbewertung anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ── 10.2 Nichtkonformität & Korrekturmaßnahme ──────────────────── */}
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 flex items-center gap-2 font-heading text-sm font-semibold"><AlertTriangle className="size-4 text-[var(--primary)]" />10.2 Nichtkonformität & Korrekturmaßnahme</p>
|
||||
{ncs.length === 0 && <p className="text-[13px] text-muted-foreground">Noch keine Nichtkonformitäten.</p>}
|
||||
<div className="space-y-1.5">
|
||||
{ncs.map((nc) => (
|
||||
<details key={nc.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer flex-wrap items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-mono text-[12px]">{nc.refNo}</span>
|
||||
<Pill tone={nc.status === "abgeschlossen" ? "ok" : nc.status === "in_bearbeitung" ? "warn" : "mut"}>{nc.status}</Pill>
|
||||
<span className="min-w-0 flex-1 truncate">{nc.description}</span>
|
||||
<span className="text-muted-foreground">{nc.actions.length} Maßnahme(n)</span>
|
||||
</summary>
|
||||
<div className="space-y-3 border-t p-3">
|
||||
{canWrite ? (
|
||||
<form action={updateNonconformity.bind(null, nc.id)} className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Herkunft<input name="source" defaultValue={nc.source} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={nc.status} className={inp}><option value="offen">offen</option><option value="in_bearbeitung">in Bearbeitung</option><option value="abgeschlossen">abgeschlossen</option></select></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId", nc.ownerId)}</label>
|
||||
</div>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Beschreibung<textarea name="description" defaultValue={nc.description} rows={2} className={ta} /></label>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Sofortkorrektur (10.2 a)<textarea name="immediateCorrection" defaultValue={nc.immediateCorrection ?? ""} rows={2} className={ta} /></label>
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-[12.5px]">{nc.description}</p>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-2">
|
||||
<p className="mb-1 text-[12px] font-semibold">Korrekturmaßnahmen (Ursache → Maßnahme → Wirksamkeit)</p>
|
||||
<ul className="space-y-2">
|
||||
{nc.actions.map((a) => (
|
||||
<li key={a.id} className="rounded-lg border bg-muted/30 p-2">
|
||||
{canWrite ? (
|
||||
<form action={updateCorrectiveAction.bind(null, a.id)} className="space-y-1.5">
|
||||
<label className="block text-[11.5px] text-muted-foreground">Maßnahme<input name="action" defaultValue={a.action} className={inp} /></label>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Ursachenanalyse (10.2 b)<textarea name="rootCause" defaultValue={a.rootCause ?? ""} rows={2} className={ta} /></label>
|
||||
<label className="block text-[11.5px] text-muted-foreground">Wirksamkeitsbewertung (10.2 d/e)<textarea name="effectivenessCheck" defaultValue={a.effectivenessCheck ?? ""} rows={2} className={ta} /></label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<label className="text-[11.5px] text-muted-foreground">Status<select name="status" defaultValue={a.status} className={inp}><option value="geplant">geplant</option><option value="umgesetzt">umgesetzt</option></select></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Fällig<input type="date" name="dueDate" defaultValue={a.dueDate ? new Date(a.dueDate).toISOString().slice(0, 10) : ""} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId", a.ownerId)}</label>
|
||||
<label className="flex items-center gap-1.5 text-[12px]"><input type="checkbox" name="effectivenessConfirmed" defaultChecked={!!a.effectivenessConfirmedAt} /> Wirksamkeit bestätigt</label>
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
{a.effectivenessConfirmedAt && <Pill tone="ok"><CheckCircle2 className="mr-1 size-3" />wirksam ({fmtDate(a.effectivenessConfirmedAt)})</Pill>}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="text-[12.5px]"><p>{a.action}</p>{a.effectivenessConfirmedAt && <Pill tone="ok">wirksam bestätigt</Pill>}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canWrite && (
|
||||
<form action={addCorrectiveAction.bind(null, nc.id)} className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<label className="flex-1 text-[11.5px] text-muted-foreground">Neue Maßnahme<input name="action" required className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Fällig<input type="date" name="dueDate" className={inp} /></label>
|
||||
<Button type="submit" size="sm">Maßnahme</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<form action={createNonconformity} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<label className="text-[11.5px] text-muted-foreground">Herkunft<input name="source" required placeholder="Internes Audit / Vorfall / Beschwerde" className={inp} /></label>
|
||||
<label className="flex-1 text-[11.5px] text-muted-foreground">Beschreibung<input name="description" required className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich{ownerSel("ownerId")}</label>
|
||||
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Nichtkonformität</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import Link from "next/link";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission, hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { RiskCatalogEntry } from "@prisma/client";
|
||||
import { adoptCatalogRisk } from "@/server/actions/risk-catalog";
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
ORG: "Organisation / Governance", HR: "Personal", PHY: "Physische Sicherheit",
|
||||
IAM: "Identitäts- & Zugriffsmanagement", CRY: "Kryptografie", OPS: "Betrieb / IT",
|
||||
NET: "Netzwerk", SUP: "Lieferanten / Cloud", DEV: "Entwicklung",
|
||||
PROTO: "Prototypenschutz", DSGVO: "Datenschutz",
|
||||
};
|
||||
|
||||
/** Risikowert-Klassifizierung (C4/VA-09): 1–4 gering · 5–9 mittel · 10–15 hoch · 16–25 sehr hoch. */
|
||||
function scoreTone(score: number): "ok" | "warn" | "risk" {
|
||||
if (score <= 4) return "ok";
|
||||
if (score <= 9) return "warn";
|
||||
return "risk";
|
||||
}
|
||||
const scoreLabel = (s: number) => (s <= 4 ? "gering" : s <= 9 ? "mittel" : s <= 15 ? "hoch" : "sehr hoch");
|
||||
|
||||
/**
|
||||
* Standard-Risikokatalog (Story A6-1, C4). Kuratierte Vorauswahl typischer IS-/TISAX-
|
||||
* Risiken mit Default-Bewertung (E×S) und Standardmaßnahme. „Übernehmen" legt das
|
||||
* Risiko im mandanteneigenen Register an (dort anpassbar; Bewertung/Maßnahmen → A6-2).
|
||||
*/
|
||||
export default async function RiskCatalogPage() {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:read");
|
||||
const canWrite = hasPermission(session, "risk:write");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [entries, adopted] = await Promise.all([
|
||||
db.riskCatalogEntry.findMany({ orderBy: [{ category: "asc" }, { orderIdx: "asc" }] }),
|
||||
db.risk.findMany({ where: { catalogCode: { not: null } }, select: { catalogCode: true } }),
|
||||
]);
|
||||
const adoptedCodes = new Set(adopted.map((r) => r.catalogCode));
|
||||
|
||||
const byCat = new Map<string, RiskCatalogEntry[]>();
|
||||
for (const e of entries) {
|
||||
const list = byCat.get(e.category) ?? [];
|
||||
list.push(e);
|
||||
byCat.set(e.category, list);
|
||||
}
|
||||
const categories = [...byCat.keys()];
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Risikoanalyse"
|
||||
title="Standard-Risikokatalog"
|
||||
sub={`${entries.length} kuratierte Risiken (C4) · Default-Bewertung E×S (5×5), im Register anpassbar`}
|
||||
actions={<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks" />}>Zum Risikoregister</Button>}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-6">
|
||||
{categories.map((cat) => (
|
||||
<section key={cat}>
|
||||
<h2 className="mb-2 font-heading text-sm font-semibold">{CATEGORY_LABEL[cat] ?? cat}</h2>
|
||||
<div className="space-y-2">
|
||||
{byCat.get(cat)!.map((e) => {
|
||||
const score = e.defaultLikelihood * e.defaultImpact;
|
||||
const isAdopted = adoptedCodes.has(e.code);
|
||||
return (
|
||||
<div key={e.code} className="shadow-card rounded-xl border bg-card p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-heading text-[13px] font-semibold">{e.code} · {e.title}</p>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">{e.description}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Pill tone={scoreTone(score)}>E{e.defaultLikelihood}×S{e.defaultImpact} = {score} ({scoreLabel(score)})</Pill>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-muted-foreground">
|
||||
{e.controls.length > 0 && <span>Controls: {e.controls.join(", ")}</span>}
|
||||
<span>· Standardmaßnahme: {e.standardMeasure}</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 border-t pt-2">
|
||||
{isAdopted ? (
|
||||
<span className="inline-flex items-center gap-1 text-[12px] text-[var(--ok)]"><Check className="size-3.5" /> Im Register übernommen</span>
|
||||
) : canWrite ? (
|
||||
<form action={adoptCatalogRisk.bind(null, e.code)}>
|
||||
<Button type="submit" size="sm" variant="outline"><Plus className="size-3.5" /> Übernehmen</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("risk");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill, SectTitle, Tag } from "@/components/mockup-ui";
|
||||
import {
|
||||
RiskCreateModal,
|
||||
RiskDetailModal,
|
||||
RiskEditModal,
|
||||
} from "@/components/risk-modals";
|
||||
import { riskLevel, riskRef, RISK_CELL_COLORS, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const RISK_INCLUDE = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
process: { select: { id: true, name: true } },
|
||||
riskAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
riskMeasures: {
|
||||
include: {
|
||||
measure: { select: { id: true, refNo: true, title: true, status: true } },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const STATUS_TONE = {
|
||||
OPEN: "risk",
|
||||
IN_TREATMENT: "warn",
|
||||
ACCEPTED: "info",
|
||||
CLOSED: "mut",
|
||||
} as const;
|
||||
|
||||
export default async function RisksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ detail?: string; edit?: string; new?: string; asset?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "risk:read");
|
||||
const t = await getTranslations("risks");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "risk:write");
|
||||
|
||||
const risks = await db.risk.findMany({
|
||||
include: { owner: { select: { name: true } } },
|
||||
orderBy: { score: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalRisk = modalId
|
||||
? await db.risk.findUnique({ where: { id: modalId }, include: RISK_INCLUDE })
|
||||
: null;
|
||||
|
||||
const needsFormData = canWrite && (params.edit || params.new);
|
||||
const [users, processes, threats, vulnerabilities] = needsFormData
|
||||
? await Promise.all([
|
||||
db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
// Globale Kataloge (kein Mandanten-Bezug)
|
||||
prisma.threat.findMany({ orderBy: { name: "asc" } }).then((r) => r.map((x) => x.name)),
|
||||
prisma.vulnerability
|
||||
.findMany({ orderBy: { name: "asc" } })
|
||||
.then((r) => r.map((x) => x.name)),
|
||||
])
|
||||
: [[], [], [], []];
|
||||
const availableAssets =
|
||||
canWrite && params.edit && modalRisk
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: modalRisk.riskAssets.map((ra) => ra.assetId) } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
const availableMeasures =
|
||||
canWrite && params.edit && modalRisk
|
||||
? await db.measure.findMany({
|
||||
where: { id: { notIn: modalRisk.riskMeasures.map((rm) => rm.measureId) } },
|
||||
select: { id: true, refNo: true, title: true },
|
||||
orderBy: { refNo: "asc" },
|
||||
})
|
||||
: [];
|
||||
// Aus dem Asset-Popup heraus angelegt: ?new=1&asset=<id>
|
||||
const preselectedAsset =
|
||||
canWrite && params.new && params.asset
|
||||
? await db.asset.findUnique({
|
||||
where: { id: params.asset },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: null;
|
||||
|
||||
// Heatmap-Zellen: Auswirkung (Y, 5 oben) × Wahrscheinlichkeit (X)
|
||||
const cell = (likelihood: number, impact: number) =>
|
||||
risks.filter((r) => r.likelihood === likelihood && r.impact === impact);
|
||||
|
||||
const legend = [
|
||||
["low", t("legendLow")],
|
||||
["medium", t("legendMedium")],
|
||||
["elevated", t("legendElevated")],
|
||||
["high", t("legendHigh")],
|
||||
["critical", t("legendCritical")],
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={tAssets("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||
{t("export")}
|
||||
</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/risks/catalog" />}>
|
||||
Risikokatalog
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/risks?new=1" />}>
|
||||
<Plus className="size-4" /> {t("newRisk")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* Heatmap */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<SectTitle title={t("heatmap")} sub={t("heatmapSub")} />
|
||||
<div className="mx-auto mt-3 grid w-full max-w-3xl grid-cols-[1.4rem_repeat(5,1fr)] gap-1">
|
||||
{[5, 4, 3, 2, 1].map((impact) => (
|
||||
<div key={impact} className="contents">
|
||||
<div className="grid place-items-center text-[11px] font-bold text-muted-foreground">
|
||||
{impact}
|
||||
</div>
|
||||
{[1, 2, 3, 4, 5].map((likelihood) => {
|
||||
const cellRisks = cell(likelihood, impact);
|
||||
return (
|
||||
<div
|
||||
key={likelihood}
|
||||
className="flex min-h-14 flex-wrap content-start gap-1 rounded-md p-1.5"
|
||||
style={{ background: RISK_CELL_COLORS[riskLevel(likelihood * impact)] }}
|
||||
title={`${likelihood} × ${impact} = ${likelihood * impact}`}
|
||||
>
|
||||
{cellRisks.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
href={`/risks?detail=${r.id}`}
|
||||
title={r.title}
|
||||
className="rounded-md bg-[var(--chip-overlay)] px-1.5 py-0.5 font-heading text-[10.5px] font-bold text-white shadow-sm hover:bg-[var(--bg-0)]"
|
||||
>
|
||||
{riskRef(r.refNo)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
<div />
|
||||
{[1, 2, 3, 4, 5].map((l) => (
|
||||
<div key={l} className="text-center text-[11px] font-bold text-muted-foreground">
|
||||
{l}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>{t("axisY")}</span>
|
||||
<span>{t("axisX")}</span>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3 text-[11.5px] text-muted-foreground">
|
||||
{legend.map(([key, label]) => (
|
||||
<span key={key} className="inline-flex items-center gap-1.5">
|
||||
<i
|
||||
className="inline-block size-3 rounded-[4px]"
|
||||
style={{ background: RISK_CELL_COLORS[key] }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Register */}
|
||||
<div className="shadow-card rounded-xl border bg-card">
|
||||
<div className="p-4 pb-0">
|
||||
<SectTitle title={t("register")} sub={t("registerSub")} />
|
||||
</div>
|
||||
<Table className="mt-1">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("id")}</TableHead>
|
||||
<TableHead>{t("risk")}</TableHead>
|
||||
<TableHead>{t("rating")}</TableHead>
|
||||
<TableHead>{t("treatment")}</TableHead>
|
||||
<TableHead>{t("status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{risks.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{risks.map((risk) => (
|
||||
<TableRow key={risk.id}>
|
||||
<TableCell className="text-muted-foreground">{riskRef(risk.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/risks?detail=${risk.id}`} className="font-bold hover:underline">
|
||||
{risk.title}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(risk.score)]}>
|
||||
{t("scoreLabel", { score: risk.score, level: tLevel(riskLevel(risk.score)) })}
|
||||
</Pill>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tag>{tTreat(risk.treatment)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalRisk && params.edit && canWrite ? (
|
||||
<RiskEditModal
|
||||
risk={modalRisk}
|
||||
users={users}
|
||||
processes={processes}
|
||||
availableAssets={availableAssets}
|
||||
availableMeasures={availableMeasures}
|
||||
threats={threats}
|
||||
vulnerabilities={vulnerabilities}
|
||||
/>
|
||||
) : modalRisk ? (
|
||||
<RiskDetailModal risk={modalRisk} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<RiskCreateModal
|
||||
users={users}
|
||||
processes={processes}
|
||||
preselectedAsset={preselectedAsset}
|
||||
threats={threats}
|
||||
vulnerabilities={vulnerabilities}
|
||||
/>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { updateIncidentIntake } from "@/server/actions/incident-intake";
|
||||
import { intakeAddress } from "@/server/incident-inbound/parse";
|
||||
import { Siren, ArrowLeft } from "lucide-react";
|
||||
|
||||
/**
|
||||
* IM-D — Mandantenseitige Ansicht/Pflege des E-Mail-Eingangs für Vorfälle.
|
||||
* Zeigt die (read-only) Intake-Adresse und lässt den Kunden-Admin Allowlist-Domänen,
|
||||
* Quelladresse und Benachrichtigungsempfänger pflegen. Sichtbar nur bei aktivem Modul
|
||||
* „Vorfälle" und `tenant:manage`.
|
||||
*/
|
||||
export default async function IncidentIntakePage() {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [moduleRow, config] = await Promise.all([
|
||||
db.tenantModule.findFirst({ where: { moduleKey: "incidents" }, select: { enabled: true } }),
|
||||
db.incidentIntakeConfig.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||
]);
|
||||
// Modul deaktiviert (oder nie provisioniert + aus) → zurück zu den Einstellungen.
|
||||
if (moduleRow && !moduleRow.enabled) redirect("/settings");
|
||||
|
||||
const address = config ? intakeAddress(config.token) : null;
|
||||
const verified = config?.status === "verifiziert";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/settings" className="mb-3 inline-flex items-center gap-1.5 text-[13px] text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-3.5" /> Einstellungen
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb="Vorfälle"
|
||||
title="E-Mail-Eingang (Vorfälle)"
|
||||
sub="Vorfälle können per E-Mail gemeldet werden — richten Sie dazu eine Weiterleitung an Ihre Intake-Adresse ein."
|
||||
/>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_320px]">
|
||||
<div className="shadow-card space-y-5 rounded-xl border bg-card p-5">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Ihre Intake-Adresse</Label>
|
||||
{address ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded-md border bg-muted px-2.5 py-1.5 text-[13px] font-mono select-all">{address}</code>
|
||||
{verified ? <Pill tone="ok">verifiziert</Pill> : <Pill tone="warn">Weiterleitung ausstehend</Pill>}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Wird beim ersten Speichern erzeugt (oder vom Betreiber beim Onboarding bereitgestellt).
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Leiten Sie Meldungen von einer der unten hinterlegten Absender-Domänen an diese Adresse weiter.
|
||||
Aus jeder eingehenden Mail wird automatisch ein Vorfall (Status „neu“) erstellt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={updateIncidentIntake} className="space-y-4 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="allowlistDomains">Erlaubte Absender-Domänen</Label>
|
||||
<Textarea
|
||||
id="allowlistDomains"
|
||||
name="allowlistDomains"
|
||||
rows={3}
|
||||
placeholder={"kunde.de\nit.kunde.de"}
|
||||
defaultValue={(config?.allowlistDomains ?? []).join("\n")}
|
||||
/>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
Eine Domäne je Zeile. Nur Mails von diesen Domänen (mit gültiger DKIM-Signatur) werden
|
||||
automatisch zu Vorfällen — alles andere geht in die Betreiber-Prüfung. Pflichtangabe für den Automatikbetrieb.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sourceAddress">Quelladresse (optional)</Label>
|
||||
<Input id="sourceAddress" name="sourceAddress" type="email" placeholder="vorfall@kunde.de" defaultValue={config?.sourceAddress ?? ""} />
|
||||
<p className="text-[11.5px] text-muted-foreground">Die konkrete Adresse, von der weitergeleitet wird — zur Dokumentation.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="notifyEmail">Benachrichtigung an (optional)</Label>
|
||||
<Input id="notifyEmail" name="notifyEmail" type="email" placeholder="isb@kunde.de" defaultValue={config?.notifyEmail ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<aside className="shadow-card h-fit space-y-3 rounded-xl border bg-card p-5 text-[12.5px] text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-foreground">
|
||||
<Siren className="size-4" /> <span className="font-semibold">So richten Sie es ein</span>
|
||||
</div>
|
||||
<ol className="list-decimal space-y-1.5 pl-4">
|
||||
<li>Absender-Domäne(n) eintragen und speichern.</li>
|
||||
<li>In Ihrem Mailsystem eine Weiterleitung auf die Intake-Adresse einrichten.</li>
|
||||
<li>Eine Test-Mail senden — sie erscheint als Vorfall, der Status wechselt auf „verifiziert“.</li>
|
||||
</ol>
|
||||
<p className="border-t pt-3">
|
||||
Die Weiterleitung bricht technisch SPF — das ist erwartet. Das Vertrauen entsteht aus Ihrer
|
||||
Domänen-Allowlist und der DKIM-Signatur Ihres Mailsystems.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
import { updateTenantSettings } from "@/server/actions/tenant-settings";
|
||||
import { SlidersHorizontal, ShieldCheck, History, Siren } from "lucide-react";
|
||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
export default async function SettingsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ audit?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const sp = await searchParams;
|
||||
|
||||
const [s, moduleRows] = await Promise.all([
|
||||
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||
db.tenantModule.findMany(),
|
||||
]);
|
||||
|
||||
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
|
||||
// Client filtert AuditLog automatisch auf den eigenen Mandanten (TENANT_MODELS/RLS).
|
||||
let auditRows: AuditRow[] = [];
|
||||
let auditActors: Record<string, string> = {};
|
||||
if (sp.audit) {
|
||||
auditRows = await db.auditLog.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
|
||||
});
|
||||
const ids = [...new Set(auditRows.map((r) => r.actorId).filter(Boolean))] as string[];
|
||||
if (ids.length) {
|
||||
const users = await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } });
|
||||
auditActors = Object.fromEntries(users.map((u) => [u.id, u.name]));
|
||||
}
|
||||
}
|
||||
const enabled = new Set(moduleRows.filter((m) => m.enabled).map((m) => m.moduleKey));
|
||||
const v = (x?: string | null) => x ?? "";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding, Sicherheits-Policy — Stammdaten speisen die ISMS-Variablen" />
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && (
|
||||
<Link href="/settings/users" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">Benutzer & Rollen</p>
|
||||
<p className="text-[12px] text-muted-foreground">Benutzer anlegen/verwalten, Rollen und Berechtigungen pflegen (mandantenintern).</p>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
<Link href="/settings/risk-criteria" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<SlidersHorizontal className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">Risikoakzeptanz & Bewertungsskala</p>
|
||||
<p className="text-[12px] text-muted-foreground">Risikoklassen, Freigabeinstanzen, Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) pflegen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
|
||||
</Link>
|
||||
|
||||
{/* IM-D: E-Mail-Eingang für Vorfälle — nur bei aktivem Modul „Vorfälle". */}
|
||||
{enabled.has("incidents") && (
|
||||
<Link href="/settings/incident-intake" className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<Siren className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">E-Mail-Eingang (Vorfälle)</p>
|
||||
<p className="text-[12px] text-muted-foreground">Intake-Adresse, erlaubte Absender-Domänen und Weiterleitung für gemeldete Vorfälle.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">Öffnen →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Audit-Trail (Aktivitätsprotokoll) — Popup wie gehabt (?audit=1) */}
|
||||
<Link href="/settings?audit=1" scroll={false} className="shadow-card flex items-center justify-between rounded-xl border bg-card p-5 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<History className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" />
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">Audit-Trail</p>
|
||||
<p className="text-[12px] text-muted-foreground">Nachvollziehbare Aktivitäten dieses Mandanten (wer hat wann was geändert).</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-[var(--primary)]">Einsehen →</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Plattform-Administration (getrennte Anmeldung — nur für Betreiber-Accounts) */}
|
||||
<Link href="/platform/login" className="shadow-card mt-3 flex items-center justify-between rounded-xl border border-dashed bg-card p-5 transition-colors hover:bg-muted/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">Plattform-Administration</p>
|
||||
<p className="text-[12px] text-muted-foreground">Übergreifendes Betreiber-Portal (Mandanten, Modul-Freischaltung, Plattform-Admins). Eigene Anmeldung erforderlich.</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[13px] font-semibold text-muted-foreground">Zum Portal →</span>
|
||||
</Link>
|
||||
|
||||
<form action={updateTenantSettings} className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
|
||||
<div className="space-y-5">
|
||||
{/* Unternehmensdaten */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Unternehmensdaten</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">Diese Werte füttern automatisch die Template-Variablen des Richtlinienmoduls (eine Pflegestelle).</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="orgName">Unternehmensname *</Label>
|
||||
<Input id="orgName" name="orgName" required defaultValue={v(s?.orgName)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="orgShort">Kurzname</Label>
|
||||
<Input id="orgShort" name="orgShort" defaultValue={v(s?.orgShort)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">Sektor</Label>
|
||||
<Input id="sector" name="sector" defaultValue={v(s?.sector)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="address">Adresse</Label>
|
||||
<Input id="address" name="address" defaultValue={v(s?.address)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="duns">D-U-N-S</Label>
|
||||
<Input id="duns" name="duns" defaultValue={v(s?.duns)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ismsScope">ISMS-Geltungsbereich (Kurz)</Label>
|
||||
<Input id="ismsScope" name="ismsScope" defaultValue={v(s?.ismsScope)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ismsScopeDescription">Geltungsbereich (Beschreibung)</Label>
|
||||
<Textarea id="ismsScopeDescription" name="ismsScopeDescription" rows={2} defaultValue={v(s?.ismsScopeDescription)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="nis2Category">NIS2-Betroffenheit</Label>
|
||||
<select id="nis2Category" name="nis2Category" defaultValue={s?.nis2Category ?? "keine"} className={`${inputCls} mt-1`}>
|
||||
<option value="keine">Keine</option>
|
||||
<option value="wichtig">Wichtige Einrichtung</option>
|
||||
<option value="wesentlich">Wesentliche Einrichtung</option>
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Steuert die NIS2-Meldefristen im Vorfall-Modul (Timer folgen).</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verantwortliche Rollen */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Verantwortliche Rollen (→ ISMS-Variablen)</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div><Label htmlFor="roleManagement">Oberste Leitung (ROLE_MANAGEMENT)</Label><Input id="roleManagement" name="roleManagement" defaultValue={v(s?.roleManagement)} className="mt-1" /></div>
|
||||
<div><Label htmlFor="roleIsb">ISB / CISO (ROLE_ISB)</Label><Input id="roleIsb" name="roleIsb" defaultValue={v(s?.roleIsb)} className="mt-1" /></div>
|
||||
<div><Label htmlFor="roleItLead">IT-Leitung (ROLE_IT_LEAD)</Label><Input id="roleItLead" name="roleItLead" defaultValue={v(s?.roleItLead)} className="mt-1" /></div>
|
||||
<div><Label htmlFor="roleDpo">Datenschutz (ROLE_DPO)</Label><Input id="roleDpo" name="roleDpo" defaultValue={v(s?.roleDpo)} className="mt-1" /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Branding & Regionales */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Branding & Regionales</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">
|
||||
Standard-Branding ist <span className="font-semibold">{BRAND.name}</span>. Eigene Werte
|
||||
überschreiben nur, was hier gesetzt ist.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div><Label htmlFor="accent">Akzentfarbe (Hex)</Label><Input id="accent" name="accent" defaultValue={v(s?.accent)} placeholder="#7d6fd6" className="mt-1" /></div>
|
||||
<div>
|
||||
<Label htmlFor="locale">Sprache</Label>
|
||||
<select id="locale" name="locale" defaultValue={s?.locale ?? "de"} className={`${inputCls} mt-1`}><option value="de">Deutsch</option><option value="en">English</option></select>
|
||||
</div>
|
||||
<div><Label htmlFor="timezone">Zeitzone</Label><Input id="timezone" name="timezone" defaultValue={s?.timezone ?? "Europe/Berlin"} className="mt-1" /></div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Logo-Upload (PNG/SVG) folgt in Phase 2 (Objektspeicher). Bis dahin — und ohne
|
||||
eigenes Logo auch danach — zeigt die Anwendung das {BRAND.name}-Logo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assessment-Level / Schutzbedarf (Kern-Einstellung: nur Betreiber) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Assessment-Level (Schutzbedarf)</p>
|
||||
<p className="text-sm">Aktuell: <span className="font-semibold">{s?.tisaxLevel ?? "AL2"}</span> — {(s?.tisaxLevel ?? "AL2") === "AL3" ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH"}</p>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">Einzige Quelle des Schutzbedarfs. Diese Kern-Einstellung wird ausschließlich vom Plattform-Betreiber im Admin-Portal gesteuert und ist hier (sowie im Onboarding-Wizard) nicht änderbar.</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full justify-center">Speichern</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Module (Übersicht, Freischaltung durch Plattform-Admin) */}
|
||||
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Freigeschaltete Module</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{MODULES.map((m) => (
|
||||
<Pill key={m.key} tone={enabled.has(m.key) ? "ok" : "mut"}>{m.name}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">Die Modul-Freischaltung erfolgt durch den Plattform-Betreiber.</p>
|
||||
</div>
|
||||
|
||||
{sp.audit && (
|
||||
<AuditTrailModal rows={auditRows} actorNames={auditActors} closeHref="/settings" sub="Aktivitäten dieses Mandanten (neueste zuerst, letzte 200)" />
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { CriteriaEditor, type Tone } from "@/app/(app)/onboarding/steps/criteria/criteria-editor";
|
||||
|
||||
/**
|
||||
* Risikoakzeptanzkriterien & Bewertungsskala (aus den Einstellungen ausgelagerte
|
||||
* Unterseite). Zentrale Pflegestelle der Risikoklassen (Schwellen & Freigabeinstanzen),
|
||||
* der Eintrittswahrscheinlichkeits-Skala und der Schadensdimensionen (C/I/A) — dieselben
|
||||
* Werte werden im Onboarding-Wizard (Schritt „Kriterien“) gespiegelt und bearbeitet.
|
||||
*/
|
||||
export default async function RiskCriteriaPage() {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "tenant:manage")) redirect("/dashboard");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const [matrixClasses, ewLevels, damageDims] = await Promise.all([
|
||||
db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||
db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
]);
|
||||
|
||||
// Serialisierbare Editor-Daten (identisch zum Wizard-Popup — eine Pflegestelle).
|
||||
const dimLvl = (val: unknown, k: string) => {
|
||||
const rec = (val ?? {}) as Record<string, unknown>;
|
||||
return typeof rec[k] === "string" ? (rec[k] as string) : "";
|
||||
};
|
||||
const editorMatrix = matrixClasses.map((c) => ({
|
||||
name: c.name,
|
||||
maxScore: c.maxScore,
|
||||
acceptance: c.acceptance,
|
||||
tone: (["ok", "warn", "orange", "risk"].includes(c.tone) ? c.tone : "warn") as Tone,
|
||||
}));
|
||||
const editorEw = ewLevels.map((e) => ({ level: e.level, label: e.label, definition: e.definition }));
|
||||
const editorDims = damageDims.map((d) => ({
|
||||
name: d.name,
|
||||
levels: {
|
||||
"1": dimLvl(d.levels, "1"),
|
||||
"2": dimLvl(d.levels, "2"),
|
||||
"3": dimLvl(d.levels, "3"),
|
||||
"4": dimLvl(d.levels, "4"),
|
||||
"5": dimLvl(d.levels, "5"),
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/settings" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> Zurück zu Einstellungen
|
||||
</Link>
|
||||
<div className="mt-3">
|
||||
<PageHead
|
||||
crumb="Organisation"
|
||||
title="Risikoakzeptanz & Bewertungsskala"
|
||||
sub="Risikoklassen (Schwellen & Freigabeinstanzen), Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) — gespiegelt im Onboarding-Wizard."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||
<CriteriaEditor
|
||||
initialMatrixClasses={editorMatrix}
|
||||
initialEwLevels={editorEw}
|
||||
initialDamageDims={editorDims}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, PERMISSIONS, ROLE_DEFS } from "@/server/rbac";
|
||||
import { createUser, updateUser, setUserRoles, setUserStatus } from "@/server/actions/tenant-users";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { UserTable } from "@/components/user-table";
|
||||
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
|
||||
import { RoleManager, CreateRoleForm } from "@/components/role-manager";
|
||||
|
||||
/**
|
||||
* Benutzer- & Rollenverwaltung im Mandanten (Paket B). Sichtbar nur mit user:manage
|
||||
* bzw. role:manage; strikt auf den eigenen Mandanten begrenzt (dbForTenant).
|
||||
* Benutzer werden über Popups (?new/?edit) angelegt/bearbeitet, die Tabelle zeigt nur.
|
||||
*/
|
||||
export default async function UsersRolesPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ new?: string; edit?: string; newRole?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const canUsers = hasPermission(session, "user:manage");
|
||||
const canRoles = hasPermission(session, "role:manage");
|
||||
if (!canUsers && !canRoles) redirect("/dashboard");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const sp = await searchParams;
|
||||
|
||||
const [users, roles] = await Promise.all([
|
||||
db.user.findMany({
|
||||
select: { id: true, name: true, email: true, status: true, userRoles: { select: { role: { select: { id: true, name: true } } } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
db.role.findMany({
|
||||
include: { rolePermissions: { include: { permission: true } }, _count: { select: { userRoles: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
const standardKeys = new Set(Object.keys(ROLE_DEFS));
|
||||
const roleOptions = roles.map((r) => ({ id: r.id, name: r.name }));
|
||||
const tableUsers = users.map((u) => ({
|
||||
id: u.id, name: u.name, email: u.email, status: u.status,
|
||||
roleNames: u.userRoles.map((ur) => ur.role.name), isSelf: u.id === session.user.id,
|
||||
}));
|
||||
const managedRoles = roles.map((r) => ({
|
||||
id: r.id, key: r.key, name: r.name, isStandard: standardKeys.has(r.key),
|
||||
permKeys: r.rolePermissions.map((rp) => rp.permission.key), userCount: r._count.userRoles,
|
||||
}));
|
||||
|
||||
const editUser = sp.edit ? users.find((u) => u.id === sp.edit) : null;
|
||||
const base = "/settings/users";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/settings" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> Zurück zu Einstellungen
|
||||
</Link>
|
||||
<div className="mt-3">
|
||||
<PageHead crumb="Organisation" title="Benutzer & Rollen" sub="Benutzer anlegen und verwalten, Rollen und Berechtigungen pflegen — nur für den eigenen Mandanten." />
|
||||
</div>
|
||||
|
||||
{canUsers && (
|
||||
<section className="mt-5">
|
||||
<h2 className="mb-3 font-heading text-sm font-semibold">Benutzer</h2>
|
||||
<UserTable users={tableUsers} newHref={`${base}?new=1`} editHref={(id) => `${base}?edit=${id}`} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canRoles && (
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-3 font-heading text-sm font-semibold">Rollen & Berechtigungen</h2>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">Rolle anklicken, um ihre Berechtigungen zu sehen und (bei eigenen Rollen) zu bearbeiten.</p>
|
||||
<RoleManager roles={managedRoles} allPermissions={[...PERMISSIONS]} newRoleHref={`${base}?newRole=1`} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canUsers && sp.new && (
|
||||
<Modal title="Benutzer anlegen" sub="Neuen Nutzer mit Rollen und Initialpasswort anlegen" closeHref={base} closeLabel="Schließen">
|
||||
<UserCreateForm action={createUser} roles={roleOptions} closeHref={base} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canRoles && sp.newRole && (
|
||||
<Modal title="Eigene Rolle anlegen" sub="Rollenname und Berechtigungen festlegen (mandantenintern)" closeHref={base} closeLabel="Schließen">
|
||||
<div className="p-5">
|
||||
<CreateRoleForm allPermissions={[...PERMISSIONS]} closeHref={base} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canUsers && editUser && (
|
||||
<Modal title={`Benutzer bearbeiten — ${editUser.name}`} sub={editUser.email} closeHref={base} closeLabel="Schließen">
|
||||
<UserEditForm
|
||||
user={{
|
||||
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
|
||||
roleIds: editUser.userRoles.map((ur) => ur.role.id), isSelf: editUser.id === session.user.id,
|
||||
}}
|
||||
roles={roleOptions}
|
||||
updateAction={updateUser.bind(null, editUser.id)}
|
||||
rolesAction={setUserRoles.bind(null, editUser.id)}
|
||||
statusAction={setUserStatus.bind(null, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { assertModuleEnabled } from "@/server/modules";
|
||||
import { SOA_EXPORT_COLUMNS, soaExportRow, toSoaCsv, isSoaEntryComplete } from "@/lib/soa";
|
||||
import { buildSoaExportInputs, ensureSoaEntries } from "@/server/soa-statement";
|
||||
import { buildSoaXlsx } from "@/server/export/soa-xlsx";
|
||||
import { buildAssessment } from "@/server/assessment";
|
||||
import { ISO_ONLY, isaControlForIso } from "@/lib/iso-isa-crosswalk";
|
||||
import { controlTitleIso } from "@/lib/control-titles-iso";
|
||||
|
||||
/**
|
||||
* Export der Anwendbarkeitserklärung + ISO-Bewertungsnachweise (AP3 + B1 Schritt 8).
|
||||
* Sichten über `?format`:
|
||||
* - `csv` (Default) / `xlsx`: SoA als Tabelle (Nachweis fürs Audit).
|
||||
* - `print`: gebrandetes, druckbares HTML (im Browser als PDF speichern).
|
||||
* - `gap`: Annex-A-Gap-Report (offene Punkte je Control) — Eingabe für die
|
||||
* Managementbewertung (9.3.2).
|
||||
* - `iso-only`: die 33 ISO-Anforderungen ohne VDA-ISA-Gegenstück — die Delta-Arbeit,
|
||||
* die ISO gegenüber TISAX zusätzlich verlangt.
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate → Modul + Recht hier prüfen.
|
||||
*/
|
||||
function esc(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
function csv(cols: string[], rows: string[][]): string {
|
||||
const q = (v: string) => (/[",\n;]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v);
|
||||
return "" + [cols.join(";"), ...rows.map((r) => r.map(q).join(";"))].join("\r\n");
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "soa:read")) return new Response("Nicht berechtigt.", { status: 403 });
|
||||
try {
|
||||
await assertModuleEnabled(session, "soa");
|
||||
} catch {
|
||||
return new Response("Modul SoA ist nicht aktiviert.", { status: 404 });
|
||||
}
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
await ensureSoaEntries(db, session.user.tenantId); // idempotent, falls noch leer
|
||||
const rows = await buildSoaExportInputs(db, session.user.tenantId);
|
||||
const format = new URL(req.url).searchParams.get("format") ?? "csv";
|
||||
|
||||
// ── B1 Schritt 8: Annex-A-Gap-Report (offene Punkte je Control) ─────────────
|
||||
if (format === "gap") {
|
||||
const asm = await buildAssessment(db, session.user.tenantId, "ISO_27001");
|
||||
const gapRows = asm.rows
|
||||
.filter((r) => r.gaps.length > 0)
|
||||
.map((r) => {
|
||||
const status = r.verdict.kind === "status" ? (r.verdict.confirmed ?? r.verdict.suggested) : "";
|
||||
const applicable = r.verdict.kind === "status" ? (r.verdict.applicable ? "ja" : "nein") : "";
|
||||
return [r.control, r.title, applicable, status, String(r.gaps.length), r.gaps.map((g) => g.missing).join(" · ")];
|
||||
});
|
||||
const body = csv(["Control", "Titel", "Anwendbar", "Umsetzungsstatus", "Offene Punkte", "Beschreibung"], gapRows);
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="annex-a-gap-report.csv"` },
|
||||
});
|
||||
}
|
||||
|
||||
// ── B1 Schritt 8 (Zusatznutzen): die 33 ISO-Anforderungen ohne ISA-Gegenstück ─
|
||||
if (format === "iso-only") {
|
||||
const isoOnlyRows = [...ISO_ONLY]
|
||||
.map((c) => [c, controlTitleIso(c), isaControlForIso(c) ? "ja" : "nein"])
|
||||
.sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }));
|
||||
const body = csv(["Control", "Titel", "ISA-Gegenstück"], isoOnlyRows);
|
||||
return new Response(body, {
|
||||
headers: { "Content-Type": "text/csv; charset=utf-8", "Content-Disposition": `attachment; filename="iso-only-delta.csv"` },
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "xlsx") {
|
||||
const buffer = await buildSoaXlsx(rows);
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": `attachment; filename="anwendbarkeitserklaerung.xlsx"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "print") {
|
||||
const complete = rows.filter(isSoaEntryComplete).length;
|
||||
const body = rows
|
||||
.map((r) => {
|
||||
const cells = soaExportRow(r).map((c) => `<td>${esc(c)}</td>`).join("");
|
||||
return `<tr class="${isSoaEntryComplete(r) ? "" : "incomplete"}">${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
const head = SOA_EXPORT_COLUMNS.map((c) => `<th>${esc(c)}</th>`).join("");
|
||||
const html = `<!doctype html><html lang="de"><head><meta charset="utf-8">
|
||||
<title>Anwendbarkeitserklärung (SoA)</title>
|
||||
<style>
|
||||
body{font:12px/1.4 system-ui,sans-serif;margin:24px;color:#111}
|
||||
h1{font-size:18px;margin:0 0 4px}
|
||||
.meta{color:#666;margin-bottom:16px}
|
||||
table{border-collapse:collapse;width:100%}
|
||||
th,td{border:1px solid #ccc;padding:4px 6px;text-align:left;vertical-align:top}
|
||||
th{background:#f3f4f6}
|
||||
tr.incomplete td{background:#fff4f4}
|
||||
@media print{.noprint{display:none}}
|
||||
</style></head><body>
|
||||
<h1>Anwendbarkeitserklärung (Statement of Applicability)</h1>
|
||||
<div class="meta">ISO/IEC 27001:2022 · ${rows.length} Controls · ${complete} vollständig · ${rows.length - complete} offen</div>
|
||||
<button class="noprint" onclick="window.print()">Drucken / als PDF speichern</button>
|
||||
<table><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>
|
||||
</body></html>`;
|
||||
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
|
||||
}
|
||||
|
||||
return new Response(toSoaCsv(rows), {
|
||||
headers: {
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
"Content-Disposition": `attachment; filename="anwendbarkeitserklaerung.csv"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { FileSpreadsheet, Printer, FileText, CheckCircle2, AlertTriangle } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ensureSoaEntries } from "@/server/soa-statement";
|
||||
import { buildAssessment } from "@/server/assessment";
|
||||
import { updateSoaEntry } from "@/server/actions/soa-entries";
|
||||
import { SOA_STATUS, SOA_STATUS_LABEL, isSoaEntryComplete, compareSoaControls } from "@/lib/soa";
|
||||
|
||||
/**
|
||||
* Anwendbarkeitserklärung (SoA, AP3 · ISO/IEC 27001:2022 6.1.3 d). Nur für Mandanten
|
||||
* mit ISO-Framework. Der Seiten-Load befüllt die 93 Annex-A-Controls idempotent vor;
|
||||
* der ISB pflegt Anwendbarkeit, Begründung/Ausschluss und Umsetzungsstatus. Modul-Gate
|
||||
* (soa) und Rechte werden im Layout bzw. hier geprüft.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
|
||||
export default async function SoaPage() {
|
||||
const session = await requireSession();
|
||||
if (!hasPermission(session, "soa:read")) redirect("/dashboard");
|
||||
const tenantId = session.user.tenantId;
|
||||
const db = dbForTenant(tenantId);
|
||||
const canWrite = hasPermission(session, "soa:write");
|
||||
|
||||
const frameworks = (await db.tenantFramework.findMany({ select: { framework: true } })).map((f) => f.framework);
|
||||
const runsIso = frameworks.includes("ISO_27001");
|
||||
|
||||
if (!runsIso) {
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="ISO 27001" title="Anwendbarkeitserklärung (SoA)" sub="Statement of Applicability nach ISO/IEC 27001." />
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-6 text-[13px] text-muted-foreground">
|
||||
Die Anwendbarkeitserklärung ist ein ISO-27001-Artefakt. Dieser Mandant führt kein ISO-27001-Framework —
|
||||
die SoA ist daher nicht anwendbar. (Framework-Zuordnung erfolgt im Betreiber-Portal.)
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// Lazy & idempotent: fehlende Controls vorbefüllen (Nutzerpflege bleibt unberührt).
|
||||
await ensureSoaEntries(db, tenantId);
|
||||
|
||||
const [entries, users, assessment] = await Promise.all([
|
||||
db.soaEntry.findMany({ where: { framework: "ISO_27001" } }),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
buildAssessment(db, tenantId, "ISO_27001"), // B1 Schritt 7: ISO-Readiness (Umsetzungsgrad)
|
||||
]);
|
||||
entries.sort((a, b) => compareSoaControls(a.control, b.control));
|
||||
|
||||
const total = entries.length;
|
||||
const complete = entries.filter(isSoaEntryComplete).length;
|
||||
const applicable = entries.filter((e) => e.applicable).length;
|
||||
const sum = assessment.summary;
|
||||
const bandTone = sum.tone === "risk" ? "risk" : sum.tone === "warn" ? "warn" : sum.tone === "info" ? "info" : sum.tone === "ok" ? "ok" : "mut";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="ISO 27001"
|
||||
title="Anwendbarkeitserklärung (SoA)"
|
||||
sub="Je Control: Anwendbarkeit, Begründung (Einbezug/Ausschluss) und Umsetzungsstatus — normative Pflichtangaben (6.1.3 d)."
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/soa/export?format=xlsx" prefetch={false} />}><FileSpreadsheet className="mr-1 size-4" />XLSX</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/soa/export?format=csv" prefetch={false} />}><FileText className="mr-1 size-4" />CSV</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/soa/export?format=print" prefetch={false} target="_blank" />}><Printer className="mr-1 size-4" />Drucken/PDF</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/soa/export?format=gap" prefetch={false} />}><FileText className="mr-1 size-4" />Gap-Report</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/soa/export?format=iso-only" prefetch={false} />}><FileText className="mr-1 size-4" />ISO-Delta (33)</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-[13px]">
|
||||
<div className="shadow-card rounded-xl border bg-card px-4 py-3"><span className="text-muted-foreground">Controls</span> <span className="ml-2 font-heading text-base font-semibold">{total}</span></div>
|
||||
<div className="shadow-card rounded-xl border bg-card px-4 py-3"><span className="text-muted-foreground">Anwendbar</span> <span className="ml-2 font-heading text-base font-semibold">{applicable}</span></div>
|
||||
<div className="shadow-card rounded-xl border bg-card px-4 py-3">
|
||||
<span className="text-muted-foreground">Vollständig</span>
|
||||
<span className="ml-2 font-heading text-base font-semibold">{complete}/{total}</span>
|
||||
{complete < total
|
||||
? <Pill tone="warn">{total - complete} offen</Pill>
|
||||
: <Pill tone="ok">vollständig</Pill>}
|
||||
</div>
|
||||
<div className="shadow-card rounded-xl border bg-card px-4 py-3">
|
||||
<span className="text-muted-foreground">{sum.metricLabel}</span>
|
||||
<span className="ml-2 font-heading text-base font-semibold">{sum.metricValue}</span>
|
||||
<Pill tone={bandTone}>{sum.band}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* B1 Schritt 7: ISO-Readiness-Aussage (Umsetzungsgrad, kein Reifegrad-Vokabular). */}
|
||||
<div className="shadow-card mt-3 rounded-xl border bg-card p-4 text-[13px]">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Zertifizierungs-Readiness (ISO/IEC 27001)</p>
|
||||
<p className="text-muted-foreground">{sum.text}</p>
|
||||
</div>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
<div className="space-y-1.5">
|
||||
{entries.map((e) => {
|
||||
const done = isSoaEntryComplete(e);
|
||||
return (
|
||||
<details key={e.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px]">
|
||||
{done ? <CheckCircle2 className="size-4 text-emerald-600" /> : <AlertTriangle className="size-4 text-amber-600" />}
|
||||
<span className="font-mono text-[12px]">{e.control}</span>
|
||||
<span className="min-w-0 flex-1 truncate">{e.title}</span>
|
||||
<Pill tone={e.applicable ? "ok" : "mut"}>{e.applicable ? "anwendbar" : "ausgeschlossen"}</Pill>
|
||||
<Pill tone={e.implementationStatus === "umgesetzt" ? "ok" : e.implementationStatus === "teilweise" ? "warn" : "mut"}>
|
||||
{SOA_STATUS_LABEL[e.implementationStatus] ?? e.implementationStatus}
|
||||
</Pill>
|
||||
</summary>
|
||||
|
||||
<div className="border-t p-3">
|
||||
{canWrite ? (
|
||||
<form action={updateSoaEntry.bind(null, e.id)} className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<label className="flex items-center gap-2 text-[12px] lg:col-span-2">
|
||||
<input type="checkbox" name="applicable" defaultChecked={e.applicable} /> Control ist anwendbar
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Umsetzungsstatus
|
||||
<select name="implementationStatus" defaultValue={e.implementationStatus} className={inp}>
|
||||
{SOA_STATUS.map((s) => <option key={s} value={s}>{SOA_STATUS_LABEL[s]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Verantwortlich
|
||||
<select name="ownerId" defaultValue={e.ownerId ?? ""} className={inp}>
|
||||
<option value="">—</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Begründung (Einbezug ODER Ausschluss) — Pflicht
|
||||
<textarea name="justification" defaultValue={e.justification} rows={2} placeholder="Warum ist der Control anwendbar bzw. warum ausgeschlossen?" className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" />
|
||||
</label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground">Herkunft/Quelle (Risiko-ID / gesetzlich / vertraglich)<input name="source" defaultValue={e.source ?? ""} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Richtlinie (Code)<input name="policyCode" defaultValue={e.policyCode ?? ""} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Nachweis (Referenz)<input name="evidenceId" defaultValue={e.evidenceId ?? ""} className={inp} /></label>
|
||||
<div className="col-span-2 mt-1 lg:col-span-4"><Button type="submit" size="sm">Speichern</Button></div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="text-[12.5px]">
|
||||
<p className="mb-1"><span className="text-muted-foreground">Begründung:</span> {e.justification || <em className="text-amber-600">fehlt</em>}</p>
|
||||
{e.source && <p><span className="text-muted-foreground">Herkunft:</span> {e.source}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für alle Routen dieses Bereichs (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("suppliers");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill, CriticalityPill, CiaBadge } from "@/components/mockup-ui";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import {
|
||||
SupplierCreateModal,
|
||||
SupplierDetailModal,
|
||||
SupplierEditModal,
|
||||
type SupplierAssetDetail,
|
||||
} from "@/components/supplier-modals";
|
||||
import {
|
||||
ServiceCreateModal,
|
||||
ServiceDetailModal,
|
||||
ServiceEditModal,
|
||||
type ServiceAssetDetail,
|
||||
} from "@/components/service-modals";
|
||||
import {
|
||||
SoftwareCreateModal,
|
||||
SoftwareDetailModal,
|
||||
SoftwareEditModal,
|
||||
type SoftwareAssetDetail,
|
||||
} from "@/components/software-modals";
|
||||
import {
|
||||
supplierRef,
|
||||
serviceRef,
|
||||
softwareRef,
|
||||
protectionLevel,
|
||||
PROTECTION_LABEL,
|
||||
buildReqContext,
|
||||
conformity,
|
||||
computedMaturity,
|
||||
CONFORMITY_TONE,
|
||||
CONFORMITY_LABEL,
|
||||
TARGET_MATURITY,
|
||||
} from "@/lib/supplier";
|
||||
import { SUPPLIER_INCLUDE, SERVICE_INCLUDE, SOFTWARE_INCLUDE } from "@/lib/supplier-include";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
export default async function SuppliersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tab?: string; detail?: string; edit?: string; new?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "supplier:read");
|
||||
const t = await getTranslations("suppliers");
|
||||
const ts = await getTranslations("services");
|
||||
const tsw = await getTranslations("software");
|
||||
const tSwStatus = await getTranslations("softwareStatus");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const params = await searchParams;
|
||||
const isServices = params.tab === "services";
|
||||
const isSoftware = params.tab === "software";
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canWrite = hasPermission(session, "supplier:write");
|
||||
|
||||
const tabs = [
|
||||
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices && !isSoftware },
|
||||
{ href: "/suppliers?tab=services", label: ts("tabServices"), active: isServices },
|
||||
{ href: "/suppliers?tab=software", label: ts("tabSoftware"), active: isSoftware },
|
||||
];
|
||||
|
||||
const newHref = isServices ? "/suppliers?tab=services&new=1" : isSoftware ? "/suppliers?tab=software&new=1" : "/suppliers?new=1";
|
||||
const newLabel = isServices ? ts("newService") : isSoftware ? tsw("newSoftware") : t("newSupplier");
|
||||
const head = (
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite && (
|
||||
<Button nativeButton={false} render={<Link href={newHref} />}>
|
||||
<Plus className="size-4" /> {newLabel}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
/* ── IT-Services ── */
|
||||
if (isServices) {
|
||||
const services = (await db.asset.findMany({
|
||||
where: { type: "IT_SERVICE", serviceProfile: { isNot: null } },
|
||||
include: SERVICE_INCLUDE,
|
||||
orderBy: { serviceProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
})) as ServiceAssetDetail[];
|
||||
|
||||
const providers = await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalService = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{ts("ref")}</TableHead>
|
||||
<TableHead>{ts("name")}</TableHead>
|
||||
<TableHead>{ts("provider")}</TableHead>
|
||||
<TableHead>{ts("protection")}</TableHead>
|
||||
<TableHead>{ts("criticality")}</TableHead>
|
||||
<TableHead>{ts("raciCoverage")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{ts("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{services.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-muted-foreground">{serviceRef(s.serviceProfile!.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers?tab=services&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.serviceProfile?.provider?.name ?? (s.serviceProfile?.internal ? ts("internal") : tc("none"))}</TableCell>
|
||||
<TableCell><CiaBadge c={s.confidentiality} i={s.integrity} a={s.availability} /></TableCell>
|
||||
<TableCell><CriticalityPill level={s.serviceProfile!.criticality} label={tCrit(String(s.serviceProfile!.criticality))} /></TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.raci.length}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalService && params.edit && canWrite ? (
|
||||
<ServiceEditModal service={modalService} providers={providers} />
|
||||
) : modalService ? (
|
||||
<ServiceDetailModal service={modalService} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<ServiceCreateModal providers={providers} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Software-Whitelist ── */
|
||||
if (isSoftware) {
|
||||
const software = (await db.asset.findMany({
|
||||
where: { type: "SOFTWARE", softwareProfile: { isNot: null } },
|
||||
include: SOFTWARE_INCLUDE,
|
||||
orderBy: { softwareProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
})) as SoftwareAssetDetail[];
|
||||
|
||||
const providers = await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalSoftware = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SOFTWARE_INCLUDE })) as SoftwareAssetDetail | null)
|
||||
: null;
|
||||
|
||||
const statusTone = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{tsw("ref")}</TableHead>
|
||||
<TableHead>{tsw("name")}</TableHead>
|
||||
<TableHead>{tsw("provider")}</TableHead>
|
||||
<TableHead>{tsw("version")}</TableHead>
|
||||
<TableHead>{tsw("approvalStatus")}</TableHead>
|
||||
<TableHead>{tsw("criticality")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{software.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{tsw("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{software.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-muted-foreground">{softwareRef(s.softwareProfile!.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers?tab=software&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.softwareProfile?.provider?.name ?? tc("none")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{s.softwareProfile?.version ?? tc("none")}</TableCell>
|
||||
<TableCell><Pill tone={statusTone[s.softwareProfile!.approvalStatus]}>{tSwStatus(s.softwareProfile!.approvalStatus)}</Pill></TableCell>
|
||||
<TableCell><CriticalityPill level={s.softwareProfile!.criticality} label={tCrit(String(s.softwareProfile!.criticality))} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalSoftware && params.edit && canWrite ? (
|
||||
<SoftwareEditModal software={modalSoftware} providers={providers} />
|
||||
) : modalSoftware ? (
|
||||
<SoftwareDetailModal software={modalSoftware} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<SoftwareCreateModal providers={providers} />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Lieferanten ── */
|
||||
const suppliers = await db.asset.findMany({
|
||||
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
|
||||
include: SUPPLIER_INCLUDE,
|
||||
orderBy: { supplierProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
});
|
||||
|
||||
const rows = suppliers.map((s) => {
|
||||
const cias = [
|
||||
Math.max(s.confidentiality, s.integrity, s.availability),
|
||||
...s.relationsFrom.map((r) => Math.max(r.relatedAsset.confidentiality, r.relatedAsset.integrity, r.relatedAsset.availability)),
|
||||
...s.relationsTo.map((r) => Math.max(r.asset.confidentiality, r.asset.integrity, r.asset.availability)),
|
||||
];
|
||||
const maxCia = Math.max(...cias);
|
||||
const level = protectionLevel(maxCia);
|
||||
const ctx = buildReqContext({
|
||||
contracts: s.contracts,
|
||||
ndas: s.ndas,
|
||||
evidence: s.evidence,
|
||||
assessments: s.assessments,
|
||||
subcontractors: s.subcontractors,
|
||||
nextReview: s.supplierProfile?.nextReview ?? null,
|
||||
linkedRiskCount: s.riskAssets.length,
|
||||
decisionCount: s.decisions.length,
|
||||
});
|
||||
return {
|
||||
s,
|
||||
level,
|
||||
conf: conformity(level, ctx),
|
||||
maturity: s.maturity?.isbValue ?? computedMaturity(level, ctx),
|
||||
linked: [...s.relationsFrom.map((r) => r.relatedAsset.name), ...s.relationsTo.map((r) => r.asset.name)],
|
||||
};
|
||||
});
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalSupplier = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("ref")}</TableHead>
|
||||
<TableHead>{t("name")}</TableHead>
|
||||
<TableHead>{t("linkedAssets")}</TableHead>
|
||||
<TableHead>{t("derivedLevel")}</TableHead>
|
||||
<TableHead>{t("maturity")}</TableHead>
|
||||
<TableHead>{t("conformity")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map(({ s, level, conf, maturity, linked }) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="text-muted-foreground">{supplierRef(s.supplierProfile!.refNo)}</TableCell>
|
||||
<TableCell>
|
||||
<Link href={`/suppliers?detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
|
||||
{s.supplierProfile?.sector && <div className="text-xs text-muted-foreground">{s.supplierProfile.sector}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-52 truncate text-muted-foreground">{linked.join(", ") || tc("none")}</TableCell>
|
||||
<TableCell>
|
||||
<Pill tone={level === 3 ? "risk" : level === 2 ? "warn" : "ok"}>Schutzbedarf {PROTECTION_LABEL[level]}</Pill>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-heading font-bold">{maturity.toFixed(1)}</span>
|
||||
<span className="text-xs text-muted-foreground"> / {TARGET_MATURITY.toFixed(1)}</span>
|
||||
</TableCell>
|
||||
<TableCell><Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{modalSupplier && params.edit && canWrite ? (
|
||||
<SupplierEditModal supplier={modalSupplier} />
|
||||
) : modalSupplier ? (
|
||||
<SupplierDetailModal supplier={modalSupplier} canWrite={canWrite} />
|
||||
) : params.new && canWrite ? (
|
||||
<SupplierCreateModal />
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für den Aufgaben-Bereich (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("tasks");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import Link from "next/link";
|
||||
import { Check, X, Sparkles, Inbox, HandMetal, Plus } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Modal } from "@/components/modal";
|
||||
import type { Domain } from "@prisma/client";
|
||||
import { KanbanBoard, type KanbanColumn } from "@/components/kanban-board";
|
||||
import { TaskDetailModal, TaskEditModal, TaskCreateModal, type TaskDetail } from "@/components/task-modals";
|
||||
import { confirmProposal, discardProposal, claimTask, updateTaskStatus, reorderTasks } from "@/server/actions/tasks";
|
||||
import { TASK_PRIORITIES, TASK_TYPE_LABELS, type TaskResources, type TaskType, type TaskLinks } from "@/lib/tasks";
|
||||
import { DOMAIN_LABELS, DOMAIN_ORDER } from "@/lib/control-domain";
|
||||
import { taskVisibilityWhere, derivedMemberDomains } from "@/server/task-visibility";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = { PROPOSED: "warn", OPEN: "info", IN_PROGRESS: "warn", DONE: "ok", REJECTED: "warn", CANCELLED: "mut", DISCARDED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { PROPOSED: "Vorschlag", OPEN: "Offen", IN_PROGRESS: "In Umsetzung", DONE: "Erledigt", REJECTED: "Abgelehnt", CANCELLED: "Zurückgezogen", DISCARDED: "Verworfen" };
|
||||
const PRIO_LABEL: Record<string, string> = { hoch: "Hoch", mittel: "Mittel", niedrig: "Niedrig" };
|
||||
const PRIO_CLASS: Record<string, string> = {
|
||||
hoch: "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]",
|
||||
mittel: "bg-[rgba(240,173,78,0.16)] text-[var(--warn)]",
|
||||
niedrig: "bg-[rgba(139,147,173,0.16)] text-muted-foreground",
|
||||
};
|
||||
const BOARD_COLUMNS = [
|
||||
{ status: "OPEN", label: "Offen" },
|
||||
{ status: "IN_PROGRESS", label: "In Umsetzung" },
|
||||
{ status: "DONE", label: "Erledigt" },
|
||||
] as const;
|
||||
|
||||
const selectCls = "h-8 rounded-md border bg-background px-2 text-sm";
|
||||
|
||||
interface TaskRow {
|
||||
id: string; type: string; title: string; status: string; priority: string; origin: string | null;
|
||||
entityType: string | null; entityRef: string | null; dueDate: Date | null;
|
||||
resources: unknown; links: unknown; domain: Domain | null; orderIdx: number;
|
||||
assigneeId: string | null; createdById: string | null; createdAt: Date;
|
||||
comments: { id: string; authorId: string | null; kind: string; body: string; createdAt: Date }[];
|
||||
participants?: { userId: string; raci: string }[];
|
||||
}
|
||||
|
||||
const PRIO_RANK: Record<string, number> = { hoch: 0, mittel: 1, niedrig: 2 };
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
return TASK_TYPE_LABELS[type as TaskType] ?? type;
|
||||
}
|
||||
|
||||
/** Verknüpfungen (Control/Dokument) als kompakte Referenz. */
|
||||
function linkRef(links: unknown): string | null {
|
||||
const l = (links ?? {}) as TaskLinks;
|
||||
const parts = [l.control && `Control ${l.control}`, l.document, l.risk && `Risiko ${l.risk}`, l.asset && `Asset ${l.asset}`].filter(Boolean);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
|
||||
function initials(n: string): string {
|
||||
return n.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("") || "—";
|
||||
}
|
||||
|
||||
/** Auto-generierter Vorschlag (Story B1): Ressourcen editierbar, dann bestätigen/verwerfen. */
|
||||
function ProposalCard({ task, users }: { task: TaskRow; users: { id: string; name: string | null }[] }) {
|
||||
const res = (task.resources ?? {}) as TaskResources;
|
||||
const ref = linkRef(task.links);
|
||||
return (
|
||||
<div className="shadow-card rounded-xl border border-warn/40 bg-warn/5 p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{task.title}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
{typeLabel(task.type)}{ref && <> · {ref}</>} · vorgeschlagen {task.createdAt.toLocaleDateString("de-DE")}
|
||||
</p>
|
||||
</div>
|
||||
<Pill tone="warn">Vorschlag</Pill>
|
||||
</div>
|
||||
|
||||
<form action={confirmProposal.bind(null, task.id)} className="mt-3 space-y-2 border-t pt-3">
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<label className="text-[11.5px] text-muted-foreground">Owner
|
||||
<select name="owner" defaultValue={task.assigneeId ?? ""} className={`${selectCls} mt-1 w-full`}>
|
||||
<option value="">— nicht zugewiesen —</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.id}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Priorität
|
||||
<select name="priority" defaultValue={task.priority} className={`${selectCls} mt-1 w-full`}>
|
||||
{TASK_PRIORITIES.map((p) => <option key={p} value={p}>{PRIO_LABEL[p]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Fällig bis
|
||||
<Input type="date" name="dueDate" defaultValue={task.dueDate ? task.dueDate.toISOString().slice(0, 10) : ""} className="mt-1 h-8" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<Input name="res_personnel" defaultValue={res.personnel ?? ""} placeholder="Personal" className="h-8" />
|
||||
<Input name="res_tool" defaultValue={res.tool ?? ""} placeholder="Tool" className="h-8" />
|
||||
<Input name="res_budget" defaultValue={res.budget ?? ""} placeholder="Budget" className="h-8" />
|
||||
<Input name="res_time" defaultValue={res.time ?? ""} placeholder="Zeit" className="h-8" />
|
||||
</div>
|
||||
<Button type="submit" size="sm"><Check className="size-4" /> Bestätigen & übernehmen</Button>
|
||||
</form>
|
||||
|
||||
<form action={discardProposal.bind(null, task.id)} className="mt-2 flex gap-2">
|
||||
<Input name="note" placeholder="Grund fürs Verwerfen (erforderlich)" required className="h-8" />
|
||||
<Button type="submit" variant="outline" size="sm"><X className="size-4" /> Verwerfen</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function TasksPage({ searchParams }: { searchParams: Promise<{ type?: string; pool?: string; proposals?: string; detail?: string; edit?: string; new?: string; domain?: string; person?: string }> }) {
|
||||
const params = await searchParams;
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const me = session.user.id;
|
||||
// PM/Leitung/Admin sehen alle Mandanten-Aufgaben; alle anderen nur eigene (+ Pool-Popup).
|
||||
const canSeeAll = hasPermission(session, "task:read_all");
|
||||
const focusId = params.edit ?? params.detail ?? null;
|
||||
const focusTask = focusId
|
||||
? await db.task.findUnique({
|
||||
where: { id: focusId },
|
||||
// Cockpit (M3, 1.6): Nachweise für das Detail mitladen.
|
||||
include: { comments: { orderBy: { createdAt: "asc" } }, evidence: { orderBy: { createdAt: "desc" } } },
|
||||
})
|
||||
: null;
|
||||
// Bestehende, noch nicht verknüpfte Nachweise — zum Verknüpfen im Detail.
|
||||
const linkableEvidence = params.detail && !params.edit
|
||||
? await db.evidence.findMany({ where: { taskId: null }, orderBy: { createdAt: "desc" }, select: { id: true, title: true, kind: true } })
|
||||
: [];
|
||||
|
||||
const [tasks, pool, users] = await Promise.all([
|
||||
db.task.findMany({
|
||||
// Cockpit (M3, 2.3): Bereichs-Sichtbarkeit + RACI (eigene/zugewiesene/Mitwirkung/Pool
|
||||
// bzw. alles bei task:read_all).
|
||||
where: taskVisibilityWhere({ userId: me, canSeeAll }),
|
||||
include: { comments: { orderBy: { createdAt: "asc" } }, participants: { select: { userId: true, raci: true } } },
|
||||
orderBy: [{ orderIdx: "asc" }, { createdAt: "desc" }],
|
||||
}),
|
||||
// Aufgaben-Pool: nicht zugewiesene, offene Aufgaben/Vorschläge — für alle Mitarbeiter sichtbar.
|
||||
db.task.findMany({
|
||||
where: { assigneeId: null, status: { in: ["PROPOSED", "OPEN"] } },
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
}),
|
||||
db.user.findMany({ select: { id: true, name: true } }),
|
||||
]);
|
||||
const name = (id: string | null) => users.find((u) => u.id === id)?.name ?? "—";
|
||||
const poolOpen = params.pool != null;
|
||||
const proposalsOpen = params.proposals != null;
|
||||
|
||||
const allRows = tasks as TaskRow[];
|
||||
const typeFilter = params.type && params.type in TASK_TYPE_LABELS ? params.type : null;
|
||||
|
||||
// Cockpit (M3, 2.3): Bereichs- + Personen-Filter. Default „meine Bereiche" —
|
||||
// ohne Funktionszuordnung (M1) aus den Bereichen abgeleitet, in denen ich beteiligt bin.
|
||||
const myDomains = derivedMemberDomains(allRows, me);
|
||||
const isDomain = (v: string): v is Domain => (DOMAIN_ORDER as string[]).includes(v);
|
||||
const domainFilter: Domain | "all" | "mine" =
|
||||
params.domain === "all" ? "all" : params.domain && isDomain(params.domain) ? params.domain : "mine";
|
||||
const personFilter = params.person && params.person !== "all" ? params.person : null;
|
||||
|
||||
const matchesDomain = (t: TaskRow): boolean => {
|
||||
if (domainFilter === "all") return true;
|
||||
if (domainFilter === "mine") return myDomains.length === 0 || (t.domain != null && myDomains.includes(t.domain));
|
||||
return t.domain === domainFilter;
|
||||
};
|
||||
const matchesPerson = (t: TaskRow): boolean =>
|
||||
!personFilter || t.assigneeId === personFilter || (t.participants ?? []).some((p) => p.userId === personFilter);
|
||||
|
||||
const rows = allRows.filter((t) => (!typeFilter || t.type === typeFilter) && matchesDomain(t) && matchesPerson(t));
|
||||
|
||||
const proposals = rows.filter((t) => t.status === "PROPOSED");
|
||||
const active = rows.filter((t) => ["OPEN", "IN_PROGRESS", "DONE"].includes(t.status));
|
||||
const terminal = rows.filter((t) => ["REJECTED", "CANCELLED", "DISCARDED"].includes(t.status));
|
||||
const myOpen = active.filter((t) => t.status !== "DONE" && t.assigneeId === me);
|
||||
// Offene (nicht erledigte) Aufgaben der aktuellen Sicht — für die Kopfzeile.
|
||||
const open = active.filter((t) => t.status !== "DONE");
|
||||
|
||||
// Kanban-Spalten: aktive Aufgaben nach Status; je Spalte nach orderIdx, dann Priorität.
|
||||
const now = new Date();
|
||||
const columns: KanbanColumn[] = BOARD_COLUMNS.map((col) => ({
|
||||
status: col.status,
|
||||
label: col.label,
|
||||
cards: active
|
||||
.filter((t) => t.status === col.status)
|
||||
.sort((a, b) => (a.orderIdx - b.orderIdx) || ((PRIO_RANK[a.priority] ?? 1) - (PRIO_RANK[b.priority] ?? 1)))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
refLabel: typeLabel(t.type),
|
||||
title: t.title,
|
||||
dueLabel: t.dueDate ? t.dueDate.toLocaleDateString("de-DE") : null,
|
||||
overdue: !!t.dueDate && t.status !== "DONE" && t.dueDate < now,
|
||||
priorityLabel: PRIO_LABEL[t.priority] ?? t.priority,
|
||||
priorityClass: PRIO_CLASS[t.priority] ?? PRIO_CLASS.mittel,
|
||||
ownerInitials: t.assigneeId ? initials(name(t.assigneeId)) : null,
|
||||
riskCount: (t.links as TaskLinks)?.risk ? 1 : 0,
|
||||
domainLabel: t.domain ? DOMAIN_LABELS[t.domain] : null,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Bereiche, die im aktuellen Datenbestand vorkommen — für die Filterleiste.
|
||||
const presentDomains = DOMAIN_ORDER.filter((d) => allRows.some((t) => t.domain === d));
|
||||
|
||||
// Typ-Filter-Chips: nur Typen, die tatsächlich vorkommen.
|
||||
const presentTypes = Array.from(new Set((tasks as TaskRow[]).map((t) => t.type))).filter((t) => t in TASK_TYPE_LABELS);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Aufgaben" title="Aufgaben & Freigaben" sub={`${proposals.length} Vorschlag/Vorschläge · ${myOpen.length} offene Freigabe(n) für Sie · ${open.length} offen ${canSeeAll ? "(alle)" : "(meine)"}`} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" nativeButton={false} render={<Link href="/tasks?new=task" />}>
|
||||
<Plus className="size-4" /> Neue Aufgabe
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/measures?new=1" />}>
|
||||
<Plus className="size-4" /> Neue Maßnahme
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/tasks?proposals=1" />}>
|
||||
<Sparkles className="size-4" /> Vorschläge{proposals.length ? ` (${proposals.length})` : ""}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/tasks?pool=1" />}>
|
||||
<Inbox className="size-4" /> Aufgaben-Pool{pool.length ? ` (${pool.length})` : ""}
|
||||
</Button>
|
||||
{canSeeAll && <Pill tone="info">Voll-Sicht (alle Aufgaben)</Pill>}
|
||||
</div>
|
||||
|
||||
{presentTypes.length > 1 && (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-1.5 text-[12px]">
|
||||
<span className="text-muted-foreground">Filter:</span>
|
||||
<Link href="/tasks" className={`rounded-full border px-2.5 py-0.5 ${!typeFilter ? "bg-foreground text-background" : "hover:bg-muted"}`}>Alle</Link>
|
||||
{presentTypes.map((t) => (
|
||||
<Link key={t} href={`/tasks?type=${t}`} className={`rounded-full border px-2.5 py-0.5 ${typeFilter === t ? "bg-foreground text-background" : "hover:bg-muted"}`}>
|
||||
{typeLabel(t)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cockpit (M3, 2.3): Bereichs- + Personen-Filter (GET-Form, serverseitig). */}
|
||||
<form method="get" className="mt-4 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
{typeFilter && <input type="hidden" name="type" value={typeFilter} />}
|
||||
<span className="text-muted-foreground">Bereich:</span>
|
||||
<select name="domain" defaultValue={domainFilter} className={selectCls}>
|
||||
<option value="mine">Meine Bereiche</option>
|
||||
<option value="all">Alle Bereiche</option>
|
||||
{DOMAIN_ORDER.map((d) => (
|
||||
<option key={d} value={d}>{DOMAIN_LABELS[d]}{presentDomains.includes(d) ? "" : " (leer)"}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-muted-foreground">Person:</span>
|
||||
<select name="person" defaultValue={personFilter ?? "all"} className={selectCls}>
|
||||
<option value="all">Alle Personen</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name ?? u.id}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline">Anwenden</Button>
|
||||
{(domainFilter !== "mine" || personFilter) && (
|
||||
<Link href={typeFilter ? `/tasks?type=${typeFilter}` : "/tasks"} className="rounded-full border px-2.5 py-0.5 hover:bg-muted">Zurücksetzen</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<section className="mt-6">
|
||||
{active.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Keine aktiven Aufgaben in dieser Sicht — Filter anpassen, oben eine neue Aufgabe anlegen oder Vorschläge übernehmen.</p>
|
||||
) : (
|
||||
<KanbanBoard columns={columns} canWrite onMove={updateTaskStatus} onReorder={reorderTasks} detailBase="/tasks" />
|
||||
)}
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">Karte anklicken für Details/Bearbeiten · Ziehen ändert Status & Reihenfolge (je Bereich gespeichert).</p>
|
||||
</section>
|
||||
|
||||
{terminal.length > 0 && (
|
||||
<details className="mt-6">
|
||||
<summary className="cursor-pointer font-heading text-sm font-semibold">Abgeschlossen / verworfen ({terminal.length})</summary>
|
||||
<ul className="mt-2 divide-y rounded-xl border bg-card">
|
||||
{terminal.map((t) => (
|
||||
<li key={t.id} className="flex items-center gap-2 p-3 text-[12.5px]">
|
||||
<Pill tone={STATUS_TONE[t.status] ?? "mut"}>{STATUS_LABEL[t.status] ?? t.status}</Pill>
|
||||
<Link href={`/tasks?detail=${t.id}`} className="truncate hover:underline">{t.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{poolOpen && (
|
||||
<Modal
|
||||
title="Aufgaben-Pool"
|
||||
sub="Nicht zugewiesene Aufgaben und Vorschläge — für alle Mitarbeitenden einsehbar. Übernehmen setzt Sie als Owner."
|
||||
closeHref="/tasks"
|
||||
closeLabel="Schließen"
|
||||
>
|
||||
{pool.length === 0 ? (
|
||||
<p className="p-5 text-sm text-muted-foreground">Aktuell sind keine Aufgaben ohne Zuweisung offen.</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{pool.map((t) => {
|
||||
const ref = linkRef(t.links);
|
||||
return (
|
||||
<li key={t.id} className="flex flex-wrap items-center gap-2 p-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[13px] font-medium">{t.title}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
{typeLabel(t.type)} · Priorität {PRIO_LABEL[t.priority] ?? t.priority}{ref && <> · {ref}</>}
|
||||
</p>
|
||||
</div>
|
||||
<Pill tone={STATUS_TONE[t.status] ?? "mut"}>{STATUS_LABEL[t.status] ?? t.status}</Pill>
|
||||
<form action={claimTask.bind(null, t.id)}>
|
||||
<Button type="submit" size="sm" variant="outline"><HandMetal className="size-4" /> Übernehmen</Button>
|
||||
</form>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{proposalsOpen && (
|
||||
<Modal
|
||||
title="Aufgaben-Vorschläge"
|
||||
sub="Automatisch erzeugte Vorschläge — Owner/Priorität/Ressourcen setzen und übernehmen oder verwerfen."
|
||||
closeHref="/tasks"
|
||||
closeLabel="Schließen"
|
||||
>
|
||||
{proposals.length === 0 ? (
|
||||
<p className="p-5 text-sm text-muted-foreground">Keine offenen Vorschläge.</p>
|
||||
) : (
|
||||
<div className="space-y-3 p-5">
|
||||
{proposals.map((t) => <ProposalCard key={t.id} task={t} users={users} />)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{params.new === "task" && <TaskCreateModal users={users} />}
|
||||
{focusTask && params.edit && (canSeeAll || focusTask.assigneeId === me || focusTask.createdById === me) && (
|
||||
<TaskEditModal task={focusTask as TaskDetail} users={users} />
|
||||
)}
|
||||
{focusTask && params.detail && !params.edit && (
|
||||
<TaskDetailModal
|
||||
task={focusTask as TaskDetail}
|
||||
me={me}
|
||||
ownerName={name(focusTask.assigneeId)}
|
||||
name={name}
|
||||
canManage={canSeeAll || focusTask.assigneeId === me || focusTask.createdById === me}
|
||||
linkableEvidence={linkableEvidence}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, History, Boxes, Users, DatabaseBackup, ShieldCheck, Download } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { MODULES } from "@/lib/modules";
|
||||
import { setTenantStatus, toggleTenantModule, setTenantTisaxLevel, setTenantMfaRequired, setTenantLocale, importPolicyPackageForTenant, setTenantFrameworks } from "@/server/actions/admin";
|
||||
import { getTenantFrameworks } from "../../../../../prisma/template-store";
|
||||
import { resolveMfaRequired } from "@/lib/mfa-policy";
|
||||
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
|
||||
import { enqueueRestore, enqueueExport, enqueueDsgvoExport } from "@/server/actions/backup-admin";
|
||||
import { listSnapshots } from "@/server/backup/export";
|
||||
import { getBackupStore } from "@/server/storage/backup-store";
|
||||
import type { BackupManifest } from "@/server/backup/serialization";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { UserTable } from "@/components/user-table";
|
||||
import { UserCreateForm, UserEditForm } from "@/components/user-forms";
|
||||
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
|
||||
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
|
||||
import { provisionIncidentIntake, setIncidentIntakeStatus } from "@/server/actions/incident-intake-admin";
|
||||
import { intakeAddress } from "@/server/incident-inbound/parse";
|
||||
import { Siren } from "lucide-react";
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
|
||||
export default async function AdminTenantPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; intake?: string }>;
|
||||
}) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const { id } = await params;
|
||||
const sp = await searchParams;
|
||||
const t = await getTranslations("admin");
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
settings: true,
|
||||
modules: true,
|
||||
roles: { select: { id: true, key: true, name: true }, orderBy: { name: "asc" } },
|
||||
users: {
|
||||
select: { id: true, name: true, email: true, status: true, identityId: true, userRoles: { select: { role: { select: { id: true, name: true } } } } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!tenant) notFound();
|
||||
|
||||
const frameworks = await getTenantFrameworks(prisma, tenant.id);
|
||||
const runsTisax = frameworks.includes("TISAX");
|
||||
const runsIso = frameworks.includes("ISO_27001");
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: t("statusActive"), SUSPENDED: t("statusSuspended"), ARCHIVED: t("statusArchived") };
|
||||
|
||||
const roleOptions = tenant.roles.map((r) => ({ id: r.id, name: r.name }));
|
||||
const tableUsers = tenant.users.map((u) => ({
|
||||
id: u.id, name: u.name, email: u.email, status: u.status, roleNames: u.userRoles.map((ur) => ur.role.name),
|
||||
}));
|
||||
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
|
||||
const base = `/admin/${tenant.id}`;
|
||||
|
||||
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
|
||||
const isOn = (key: string) => moduleState.get(key) ?? true;
|
||||
|
||||
// IM-D: Intake-Konfiguration (E-Mail-Eingang für Vorfälle). Nur relevant bei aktivem Modul.
|
||||
const incidentsEnabled = isOn("incidents");
|
||||
const intakeConfig = incidentsEnabled
|
||||
? await prisma.incidentIntakeConfig.findUnique({ where: { tenantId: id } })
|
||||
: null;
|
||||
|
||||
const s = tenant.settings;
|
||||
|
||||
// Hauptkontakt (bestätigte Entscheidung): AUS den tenant-admin-Usern ABLEITEN —
|
||||
// keine eigenen TenantSettings-Felder, keine Migration. Aktive Mitglieder mit der
|
||||
// Standardrolle "tenant-admin" (RBAC-Key) gelten als Hauptkontakt.
|
||||
const adminRoleIds = new Set(tenant.roles.filter((r) => r.key === "tenant-admin").map((r) => r.id));
|
||||
const mainContacts = tenant.users.filter(
|
||||
(u) => u.status === "ACTIVE" && u.userRoles.some((ur) => adminRoleIds.has(ur.role.id)),
|
||||
);
|
||||
|
||||
// Audit-Trail dieses Mandanten (nur laden, wenn Popup offen). Als Superadmin
|
||||
// cross-tenant über den Owner-Client, explizit auf diesen Mandanten gefiltert.
|
||||
let auditRows: AuditRow[] = [];
|
||||
let auditActors: Record<string, string> = {};
|
||||
if (sp.audit) {
|
||||
auditRows = await prisma.auditLog.findMany({
|
||||
where: { tenantId: id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 200,
|
||||
select: { id: true, createdAt: true, actorId: true, action: true, entity: true, entityId: true, scope: true },
|
||||
});
|
||||
const actorIds = [...new Set(auditRows.map((r) => r.actorId).filter(Boolean))] as string[];
|
||||
if (actorIds.length) {
|
||||
const users = await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } });
|
||||
auditActors = Object.fromEntries(users.map((u) => [u.id, u.name]));
|
||||
}
|
||||
}
|
||||
|
||||
const usersBase = `${base}?users=1`;
|
||||
|
||||
// ── Datensicherung & DSGVO (Betreiber-Portal) ──────────────────────────────
|
||||
// Nur Voll-Admins dürfen Restore/Export/DSGVO auslösen; MFA-Status steuert das
|
||||
// Step-up-Feld. Die Session ist im (platform)/layout bereits erzwungen.
|
||||
const platformSession = await platformAuth();
|
||||
const currentAdmin = platformSession?.user?.id
|
||||
? await prisma.platformAdmin.findUnique({ where: { id: platformSession.user.id }, select: { role: true, mfaEnrolledAt: true } })
|
||||
: null;
|
||||
const isFullAdmin = currentAdmin?.role === "full";
|
||||
const adminMfaEnrolled = currentAdmin?.mfaEnrolledAt != null;
|
||||
|
||||
// Sicherungspunkte + Dry-run-Vorschau (Manifest je Snapshot, nur wenn Popup offen).
|
||||
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/MinIO nicht konfiguriert,
|
||||
// Bucket fehlt, Creds falsch) darf die Betreiber-Konsole NICHT auf eine Fehlerseite
|
||||
// werfen — die Liste bleibt dann leer und das Popup zeigt einen Hinweis.
|
||||
const snapshotOptions: SnapshotOption[] = [];
|
||||
let snapshotStoreError = false;
|
||||
if (isFullAdmin && (sp.restore || sp.dsgvo)) {
|
||||
try {
|
||||
const store = await getBackupStore();
|
||||
const ids = await listSnapshots(id);
|
||||
for (const snapshotId of ids.reverse()) {
|
||||
const raw = await store.get(`${id}/backups/${snapshotId}/manifest.json`);
|
||||
let manifest: BackupManifest | null = null;
|
||||
if (raw) { try { manifest = JSON.parse(raw.toString("utf8")) as BackupManifest; } catch { manifest = null; } }
|
||||
snapshotOptions.push({
|
||||
snapshotId,
|
||||
snapshotAt: manifest?.snapshotAt ?? null,
|
||||
totalRows: manifest?.totalRows ?? null,
|
||||
artifactTenantSlug: manifest?.tenantSlug ?? null,
|
||||
tenantMismatch: manifest ? manifest.tenantId !== id : false,
|
||||
tables: (manifest?.tables ?? []).filter((tm) => tm.rowCount > 0).map((tm) => ({ model: tm.model, rows: tm.rowCount })).sort((a, b) => b.rows - a.rows),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
snapshotStoreError = true;
|
||||
console.error("[backup] Sicherungsspeicher nicht erreichbar (list/get):", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Betroffene Personen (für Per-Person-DSGVO-Auskunft).
|
||||
const subjectOptions: SubjectOption[] = tenant.users.map((u) => ({
|
||||
identityId: u.identityId,
|
||||
label: `${u.name} · ${u.email}`,
|
||||
})).filter((s) => s.identityId);
|
||||
|
||||
// Letzte Jobs dieses Mandanten (Status/Ergebnis-Anzeige).
|
||||
const backupJobs = isFullAdmin
|
||||
? await prisma.backupJob.findMany({
|
||||
where: { tenantId: id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
select: { id: true, kind: true, status: true, snapshotId: true, error: true, createdAt: true, downloadToken: true, downloadExpiresAt: true },
|
||||
})
|
||||
: [];
|
||||
const jobKindLabel: Record<string, string> = { tenant_restore: "Restore", tenant_export: "Export", dsgvo_export: "DSGVO-Export" };
|
||||
const jobStatusTone: Record<string, "ok" | "warn" | "mut"> = { done: "ok", queued: "warn", running: "warn", failed: "mut" };
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/admin" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> {t("backToOverview")}
|
||||
</Link>
|
||||
<div className="mt-3">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={tenant.name}
|
||||
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "", level: s?.tisaxLevel ?? "AL2" })}
|
||||
actions={<Pill tone={STATUS_TONE[tenant.status]}>{STATUS_LABEL[tenant.status]}</Pill>}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-[1fr_320px]">
|
||||
{/* Hauptfenster: Stammdaten + Hauptkontakt + Verwaltung (Module/Benutzer als Popup) */}
|
||||
<div className="space-y-5">
|
||||
{/* Stammdaten (aus TenantSettings — einzige Quelle) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("masterDataTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("masterDataHint")}</p>
|
||||
<dl className="grid gap-x-6 gap-y-2.5 sm:grid-cols-2">
|
||||
<Field label={t("orgName")} value={s?.orgName ?? tenant.name} />
|
||||
<Field label={t("orgShort")} value={s?.orgShort ?? tenant.short} empty={t("notSet")} />
|
||||
<Field label={t("slug")} value={tenant.slug} />
|
||||
<Field label={t("sector")} value={s?.sector ?? tenant.sector} empty={t("notSet")} />
|
||||
<Field label={t("address")} value={s?.address} empty={t("notSet")} />
|
||||
<Field label={t("duns")} value={s?.duns} empty={t("notSet")} />
|
||||
<Field label={t("ismsScope")} value={s?.ismsScope} empty={t("notSet")} />
|
||||
<Field label={t("tisaxLevel")} value={s?.tisaxLevel ?? "AL2"} />
|
||||
<Field label={t("status")} value={STATUS_LABEL[tenant.status]} />
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Hauptkontakt (abgeleitet aus tenant-admin) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("mainContactTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("mainContactHint")}</p>
|
||||
{mainContacts.length ? (
|
||||
<ul className="space-y-2">
|
||||
{mainContacts.map((c) => (
|
||||
<li key={c.id} className="flex items-center justify-between gap-3 rounded-lg border bg-muted/40 px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{c.name}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{c.email}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("mainContactNone")}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verwaltung: Module & Benutzer als Popup (bestehendes ?param/<Modal>-Muster) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("manageTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("manageHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={`${base}?modules=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2"><Boxes className="size-4" /> {t("manageModules")}</Button>
|
||||
</Link>
|
||||
<Link href={usersBase} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2"><Users className="size-4" /> {t("manageUsers")} · {t("usersCount", { count: tenant.users.length })}</Button>
|
||||
</Link>
|
||||
{incidentsEnabled && (
|
||||
<Link href={`${base}?intake=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
<Siren className="size-4" /> E-Mail-Eingang
|
||||
{intakeConfig ? (
|
||||
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
|
||||
{intakeConfig.status === "verifiziert" ? "verifiziert" : "ausstehend"}
|
||||
</Pill>
|
||||
) : (
|
||||
<Pill tone="mut">nicht eingerichtet</Pill>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Lebenszyklus */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{t("lifecycleTitle")}</p>
|
||||
<div className="space-y-2">
|
||||
<form action={setTenantStatus.bind(null, tenant.id, "ACTIVE")}>
|
||||
<Button type="submit" variant={tenant.status === "ACTIVE" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={tenant.status === "ACTIVE"}>{t("lifecycleActivate")}</Button>
|
||||
</form>
|
||||
<form action={setTenantStatus.bind(null, tenant.id, "SUSPENDED")}>
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center" disabled={tenant.status === "SUSPENDED"}>{t("lifecycleSuspend")}</Button>
|
||||
</form>
|
||||
<form action={setTenantStatus.bind(null, tenant.id, "ARCHIVED")}>
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full justify-center" disabled={tenant.status === "ARCHIVED"}>{t("lifecycleArchive")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">{t("lifecycleNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* Normen/Rahmenwerke je Mandant — nachträglich aktivierbar/deaktivierbar */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("frameworksTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">
|
||||
{t("frameworksHint", { list: frameworks.map((f) => (f === "TISAX" ? t("frameworksTisax") : t("frameworksIso"))).join(" + ") })}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px]">{t("frameworksTisax")}</span>
|
||||
{runsTisax ? (
|
||||
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "TISAX"))}>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={!runsIso}>{t("frameworksDeactivate")}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "TISAX" as const])}>
|
||||
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[13px]">{t("frameworksIso")}</span>
|
||||
{runsIso ? (
|
||||
<form action={setTenantFrameworks.bind(null, tenant.id, frameworks.filter((f) => f !== "ISO_27001"))}>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={!runsTisax}>{t("frameworksDeactivate")}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setTenantFrameworks.bind(null, tenant.id, [...frameworks, "ISO_27001" as const])}>
|
||||
<Button type="submit" variant="secondary" size="sm">{t("frameworksActivate")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">{t("frameworksNote")}</p>
|
||||
</div>
|
||||
|
||||
{/* Kern-Einstellung: Assessment-Level / Schutzbedarf (nur Superadmin) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("assessmentTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("assessmentHint", { level: s?.tisaxLevel ?? "AL2" })}</p>
|
||||
<div className="space-y-2">
|
||||
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL2")}>
|
||||
<Button type="submit" variant={(s?.tisaxLevel ?? "AL2") === "AL2" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.tisaxLevel ?? "AL2") === "AL2"}>{t("assessmentAl2")}</Button>
|
||||
</form>
|
||||
<form action={setTenantTisaxLevel.bind(null, tenant.id, "AL3")}>
|
||||
<Button type="submit" variant={s?.tisaxLevel === "AL3" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.tisaxLevel === "AL3"}>{t("assessmentAl3")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SEC3-a: MFA-Pflicht je Mandant (nur Superadmin) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("mfaTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("mfaHint", { state: resolveMfaRequired(s?.securityPolicy) ? t("mfaStateOn") : t("mfaStateOff") })}</p>
|
||||
<div className="space-y-2">
|
||||
<form action={setTenantMfaRequired.bind(null, tenant.id, true)}>
|
||||
<Button type="submit" variant={resolveMfaRequired(s?.securityPolicy) ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={resolveMfaRequired(s?.securityPolicy)}>{t("mfaActivate")}</Button>
|
||||
</form>
|
||||
<form action={setTenantMfaRequired.bind(null, tenant.id, false)}>
|
||||
<Button type="submit" variant={!resolveMfaRequired(s?.securityPolicy) ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={!resolveMfaRequired(s?.securityPolicy)}>{t("mfaDeactivate")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sprachwahl je Mandant (steuert die Import-Sprache des Vorlagenpakets) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("policyLangTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("policyLangHint", { lang: s?.locale === "en" ? t("policyLangEn") : t("policyLangDe") })}</p>
|
||||
<div className="space-y-2">
|
||||
<form action={setTenantLocale.bind(null, tenant.id, "de")}>
|
||||
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("policyLangDe")}</Button>
|
||||
</form>
|
||||
<form action={setTenantLocale.bind(null, tenant.id, "en")}>
|
||||
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("policyLangEn")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Audit-Trail (Aktivitätsprotokoll dieses Mandanten) — Popup wie gehabt */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">{t("auditTitle")}</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("auditHint")}</p>
|
||||
<Link href={`${base}?audit=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><History className="size-4" /> {t("auditView")}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Datensicherung & DSGVO (nur Voll-Admin) — Restore ist DESTRUKTIV */}
|
||||
{isFullAdmin && (
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Datensicherung & DSGVO</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">Betreiber-Aktionen: Sicherung erstellen, Mandant wiederherstellen (destruktiv, MFA + Bestätigung), DSGVO-Paket zustellen.</p>
|
||||
<div className="space-y-2">
|
||||
<Link href={`${base}?export=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><DatabaseBackup className="size-4" /> Export jetzt</Button>
|
||||
</Link>
|
||||
<Link href={`${base}?restore=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="w-full justify-center gap-2 border-destructive/40 text-destructive hover:bg-destructive/10"><ShieldCheck className="size-4" /> Wiederherstellen…</Button>
|
||||
</Link>
|
||||
<Link href={`${base}?dsgvo=1`} scroll={false}>
|
||||
<Button variant="outline" size="sm" className="w-full justify-center gap-2"><Download className="size-4" /> DSGVO-Export</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{backupJobs.length > 0 && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<p className="mb-1.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Letzte Jobs</p>
|
||||
<ul className="space-y-1.5">
|
||||
{backupJobs.map((j) => {
|
||||
const linkLive = j.kind === "dsgvo_export" && j.status === "done" && !!j.downloadToken && isDownloadLive(j.downloadExpiresAt);
|
||||
return (
|
||||
<li key={j.id} className="flex items-center justify-between gap-2 text-[12px]">
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone={jobStatusTone[j.status] ?? "mut"}>{j.status}</Pill>
|
||||
<span>{jobKindLabel[j.kind] ?? j.kind}</span>
|
||||
</span>
|
||||
{linkLive ? (
|
||||
<a href={`/api/platform/dsgvo/${j.downloadToken}`} className="font-medium text-primary hover:underline">Download</a>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{new Date(j.createdAt).toLocaleString()}</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{backupJobs.some((j) => j.status === "failed") && (
|
||||
<p className="mt-1.5 text-[11px] text-destructive">{backupJobs.find((j) => j.status === "failed")?.error}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module → Popup (?modules=1) */}
|
||||
{sp.modules && (
|
||||
<Modal title={t("modulesTitle")} sub={t("modulesModalSub", { name: tenant.name })} closeHref={base} closeLabel={t("close")}>
|
||||
<div className="p-5">
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">{t("modulesHint")}</p>
|
||||
<div className="divide-y">
|
||||
{MODULES.map((m) => {
|
||||
const on = isOn(m.key);
|
||||
return (
|
||||
<div key={m.key} className="flex items-center justify-between gap-3 py-2.5">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{m.name}</p>
|
||||
<p className="text-[11px] text-muted-foreground">{m.href}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{m.key === "policies" && on && (
|
||||
<form action={importPolicyPackageForTenant.bind(null, tenant.id)}>
|
||||
<button type="submit" className="rounded-full border px-3 py-1 text-[12px] font-medium hover:bg-muted" title={t("importTemplatesTitle")}>
|
||||
{t("importTemplates")}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
<Pill tone={on ? "ok" : "mut"}>{on ? t("moduleActive") : t("moduleInactive")}</Pill>
|
||||
<form action={toggleTenantModule.bind(null, tenant.id, m.key, !on)}>
|
||||
<Button type="submit" size="sm" variant="outline">{on ? t("moduleDeactivate") : t("moduleActivate")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Benutzer → Popup (?users=1); Anlegen/Bearbeiten laufen verschachtelt (?new/?edit) */}
|
||||
{sp.users && (
|
||||
<Modal title={`${t("manageUsers")} — ${tenant.name}`} sub={t("usersModalSub", { name: tenant.name })} closeHref={base} closeLabel={t("close")}>
|
||||
<div className="p-5">
|
||||
<UserTable users={tableUsers} newHref={`${usersBase}&new=1`} editHref={(uid) => `${usersBase}&edit=${uid}`} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{sp.users && sp.new && (
|
||||
<Modal title={t("userCreate")} sub={t("userCreateSub", { name: tenant.name })} closeHref={usersBase} closeLabel={t("close")}>
|
||||
<UserCreateForm action={createTenantUser.bind(null, tenant.id)} roles={roleOptions} closeHref={usersBase} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{sp.users && editUser && (
|
||||
<Modal title={t("userEdit", { name: editUser.name })} sub={editUser.email} closeHref={usersBase} closeLabel={t("close")}>
|
||||
<UserEditForm
|
||||
user={{
|
||||
id: editUser.id, name: editUser.name, email: editUser.email, status: editUser.status,
|
||||
roleIds: editUser.userRoles.map((ur) => ur.role.id),
|
||||
}}
|
||||
roles={roleOptions}
|
||||
updateAction={updateTenantUser.bind(null, tenant.id, editUser.id)}
|
||||
rolesAction={setTenantUserRoles.bind(null, tenant.id, editUser.id)}
|
||||
statusAction={setTenantUserStatus.bind(null, tenant.id, editUser.id, editUser.status === "ACTIVE" ? "DEACTIVATED" : "ACTIVE")}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{sp.audit && (
|
||||
<AuditTrailModal rows={auditRows} actorNames={auditActors} closeHref={base} sub={t("auditSub", { name: tenant.name })} />
|
||||
)}
|
||||
|
||||
{/* Export jetzt → Popup (?export=1) */}
|
||||
{isFullAdmin && sp.export && (
|
||||
<Modal title="Export jetzt" sub={`On-demand-Sicherung — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
|
||||
<ExportModalBody tenantId={tenant.id} action={enqueueExport} mfaEnrolled={adminMfaEnrolled} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* Portal-Restore → Popup (?restore=1) — destruktiv */}
|
||||
{isFullAdmin && sp.restore && (
|
||||
<Modal title="Mandant wiederherstellen" sub={`Destruktiver Restore — ${tenant.name} (${tenant.slug})`} closeHref={base} closeLabel={t("close")}>
|
||||
<RestoreModalBody tenantId={tenant.id} tenantSlug={tenant.slug} snapshots={snapshotOptions} action={enqueueRestore} mfaEnrolled={adminMfaEnrolled} storeError={snapshotStoreError} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* DSGVO-Export → Popup (?dsgvo=1) */}
|
||||
{isFullAdmin && sp.dsgvo && (
|
||||
<Modal title="DSGVO-Export" sub={`Auskunft/Portabilität — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
|
||||
<DsgvoModalBody tenantId={tenant.id} action={enqueueDsgvoExport} subjects={subjectOptions} mfaEnrolled={adminMfaEnrolled} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* IM-D: E-Mail-Eingang provisionieren → Popup (?intake=1) */}
|
||||
{incidentsEnabled && sp.intake && (
|
||||
<Modal title="E-Mail-Eingang für Vorfälle" sub={`Intake-Provisionierung — ${tenant.name}`} closeHref={base} closeLabel={t("close")}>
|
||||
<div className="space-y-5 p-5">
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">Intake-Adresse</p>
|
||||
{intakeConfig ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<code className="rounded-md border bg-muted px-2.5 py-1.5 text-[13px] font-mono select-all">{intakeAddress(intakeConfig.token)}</code>
|
||||
<Pill tone={intakeConfig.status === "verifiziert" ? "ok" : "warn"}>
|
||||
{intakeConfig.status === "verifiziert" ? "verifiziert" : "Weiterleitung ausstehend"}
|
||||
</Pill>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12.5px] text-muted-foreground">Wird beim Speichern erzeugt (global eindeutiger Token).</p>
|
||||
)}
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
Der Kunde richtet eine Weiterleitung von seiner Adresse auf diese Intake-Adresse ein. Aus jeder
|
||||
eingehenden Mail (erlaubte Domäne + DKIM) wird automatisch ein Vorfall.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={provisionIncidentIntake.bind(null, tenant.id)} className="space-y-3 border-t pt-4">
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="intake-domains" className="text-[12.5px] font-medium">Erlaubte Absender-Domänen</label>
|
||||
<textarea
|
||||
id="intake-domains"
|
||||
name="allowlistDomains"
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm"
|
||||
placeholder={"kunde.de\nit.kunde.de"}
|
||||
defaultValue={(intakeConfig?.allowlistDomains ?? []).join("\n")}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Eine je Zeile. Pflicht für den Automatikbetrieb.</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="intake-source" className="text-[12.5px] font-medium">Quelladresse (optional)</label>
|
||||
<input
|
||||
id="intake-source"
|
||||
name="sourceAddress"
|
||||
type="email"
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
placeholder="vorfall@kunde.de"
|
||||
defaultValue={intakeConfig?.sourceAddress ?? ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm">{intakeConfig ? "Speichern" : "Intake anlegen"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{intakeConfig && (
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Status manuell setzen (i. d. R. automatisch bei erster Test-Mail).
|
||||
</p>
|
||||
{intakeConfig.status === "verifiziert" ? (
|
||||
<form action={setIncidentIntakeStatus.bind(null, tenant.id, false)}>
|
||||
<Button type="submit" variant="ghost" size="sm">Auf „ausstehend“ zurücksetzen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<form action={setIncidentIntakeStatus.bind(null, tenant.id, true)}>
|
||||
<Button type="submit" variant="outline" size="sm">Als verifiziert markieren</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** TTL-Prüfung des DSGVO-Download-Links (Modul-Scope: hält die Render-Funktion rein). */
|
||||
function isDownloadLive(expiresAt: Date | null): boolean {
|
||||
return !!expiresAt && expiresAt.getTime() > Date.now();
|
||||
}
|
||||
|
||||
/** Kleines Stammdaten-Feld (Label + Wert, Fallback bei leer). */
|
||||
function Field({ label, value, empty }: { label: string; value?: string | null; empty?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">{label}</dt>
|
||||
<dd className="text-[13.5px]">{value && value.trim() ? value : (empty ?? "—")}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft, DatabaseBackup } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { getBackupTargetView, saveBackupTarget, testBackupConnection } from "@/server/actions/backup-settings";
|
||||
import { BackupTargetForm } from "@/components/backup-target-form";
|
||||
|
||||
/**
|
||||
* Betreiber-Portal → Backup-Zielspeicher (Lane „Konfigurierbarer Backup-Zielspeicher").
|
||||
* Zugriff: Plattform-Session + MFA-Policy erzwingt bereits das (platform)/layout;
|
||||
* die Zielspeicher-Konfiguration ist zusätzlich auf Voll-Admins beschränkt
|
||||
* (Server-Actions rufen requirePlatformFullAdmin; hier wird Read-only sauber abgefangen).
|
||||
*/
|
||||
export default async function BackupTargetPage() {
|
||||
const session = await platformAuth();
|
||||
const admin = session?.user?.id
|
||||
? await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true, mfaEnrolledAt: true },
|
||||
})
|
||||
: null;
|
||||
const isFullAdmin = admin?.role === "full";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<Link href="/admin" className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" /> Zur Mandantenverwaltung
|
||||
</Link>
|
||||
<div className="mt-3">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Backup-Zielspeicher"
|
||||
sub="Ziel der Backup-/DSGVO-Artefakte konfigurieren: lokal (persistentes Volume) oder S3/MinIO."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!isFullAdmin ? (
|
||||
<div className="shadow-card mt-4 max-w-2xl rounded-xl border bg-card p-5">
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Diese Einstellung ist nur für <strong>Voll-Administratoren</strong> zugänglich (Betreiber-Config mit Credentials).
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 max-w-2xl space-y-4">
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 flex items-center gap-2 font-heading text-sm font-semibold">
|
||||
<DatabaseBackup className="size-4" /> Zielspeicher
|
||||
</p>
|
||||
<p className="mb-4 text-[12px] text-muted-foreground">
|
||||
Die Auswahl gilt für neue Sicherungen, Restores und DSGVO-Pakete. „Verbindung testen“ legt kurz ein
|
||||
winziges Test-Objekt an (put/get/remove) und entfernt es sofort wieder. Das S3-Secret wird verschlüsselt
|
||||
gespeichert; die <strong>Artefakt-Verschlüsselung</strong> (BACKUP_ENC_KEY) ist davon getrennt.
|
||||
</p>
|
||||
<BackupTargetForm
|
||||
initial={await getBackupTargetView()}
|
||||
saveAction={saveBackupTarget}
|
||||
testAction={testBackupConnection}
|
||||
mfaEnrolled={admin?.mfaEnrolledAt != null}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Hinweis: Bestehende Env-Deployments (S3_*/BACKUP_LOCAL_DIR) laufen unverändert weiter — sobald hier ein Ziel
|
||||
gespeichert wird, hat die DB-Config Vorrang vor den Env-Variablen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { createTenant } from "@/server/actions/admin";
|
||||
import { getMailStatus } from "@/server/actions/mail";
|
||||
import { MailStatusPanel } from "@/components/mail-status-panel";
|
||||
import { resolveInboundReview } from "@/server/actions/incident-intake-admin";
|
||||
import { intakeAddress } from "@/server/incident-inbound/parse";
|
||||
import { Siren } from "lucide-react";
|
||||
|
||||
const REVIEW_REASON_LABEL: Record<string, string> = {
|
||||
no_token: "kein Token",
|
||||
unknown_token: "unbekannter Token",
|
||||
allowlist_failed: "Absender nicht in Allowlist",
|
||||
dkim_failed: "DKIM fehlgeschlagen",
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", SUSPENDED: "Gesperrt", ARCHIVED: "Archiviert" };
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
export default async function AdminPage({ searchParams }: { searchParams: Promise<{ new?: string }> }) {
|
||||
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
const params = await searchParams;
|
||||
|
||||
const [tenants, mailStatus, pendingIntake, openReviews] = await Promise.all([
|
||||
prisma.tenant.findMany({
|
||||
include: { _count: { select: { users: true } }, modules: true },
|
||||
orderBy: { createdAt: "asc" },
|
||||
}),
|
||||
// SEC1: Betriebszustand der Mail-Strecke + Testversand.
|
||||
getMailStatus(),
|
||||
// IM-D: Kunden, deren E-Mail-Weiterleitung noch nicht verifiziert ist.
|
||||
prisma.incidentIntakeConfig.findMany({
|
||||
where: { status: "weiterleitung_ausstehend" },
|
||||
select: { tenantId: true, token: true },
|
||||
}),
|
||||
// IM-D: Inbound-Mails ohne/mit unbekanntem Token → Betreiber-Sichtung.
|
||||
prisma.incidentInboundReview.findMany({
|
||||
where: { status: "offen" },
|
||||
orderBy: { receivedAt: "desc" },
|
||||
take: 50,
|
||||
select: { id: true, sender: true, subject: true, recipient: true, reason: true, receivedAt: true },
|
||||
}),
|
||||
]);
|
||||
// Mandantennamen zu den offenen Verifizierungen (mandantenübergreifende Betreiber-Sicht).
|
||||
const pendingTenantNames = new Map(
|
||||
tenants.filter((t) => pendingIntake.some((p) => p.tenantId === t.id)).map((t) => [t.id, t.name]),
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Admin-Konsole — Mandantenverwaltung"
|
||||
sub="Kunden (Mandanten) anlegen, provisionieren, Module & Lebenszyklus verwalten"
|
||||
actions={
|
||||
<span className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admins" />}>Administratoren</Button>
|
||||
<Button nativeButton={false} render={<Link href={params.new ? "/admin" : "/admin?new=1"} />}>
|
||||
<Plus className="size-4" /> Neuer Kunde
|
||||
</Button>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{params.new && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">Neuen Kunden anlegen</p>
|
||||
<form action={createTenant} className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">Firmenname *</Label>
|
||||
<Input id="name" name="name" required className="mt-1" placeholder="Muster GmbH" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="short">Kurzname</Label>
|
||||
<Input id="short" name="short" className="mt-1" placeholder="Muster" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="slug">Kürzel/Slug (optional)</Label>
|
||||
<Input id="slug" name="slug" className="mt-1" placeholder="wird aus Name erzeugt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">Sektor</Label>
|
||||
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Automotive-Zulieferer" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
|
||||
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminName">Admin-Name</Label>
|
||||
<Input id="adminName" name="adminName" className="mt-1" placeholder="Vor- und Nachname" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="adminPassword">Initial-Passwort * (min. 8)</Label>
|
||||
<Input id="adminPassword" name="adminPassword" type="text" required className="mt-1" placeholder="wird dem Admin mitgeteilt" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tisaxLevel">TISAX-Level</Label>
|
||||
<select id="tisaxLevel" name="tisaxLevel" defaultValue="AL2" className={`${inputCls} mt-1`}>
|
||||
<option value="AL2">AL2 (MUSS · SOLL · HOCH)</option>
|
||||
<option value="AL3">AL3 (+ SEHR HOCH)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Rahmenwerk(e)</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-4 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_tisax" defaultChecked /> TISAX / VDA ISA
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="fw_iso" /> ISO/IEC 27001
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Mind. eines wählen (nichts gewählt = TISAX). Bei beiden ist TISAX das Primär-Framework; die Anforderungssichten koexistieren je Dokument.
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="seedPolicies" defaultChecked /> Richtlinienpaket beim Anlegen ausrollen (je gewähltem Rahmenwerk)
|
||||
</label>
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<Button type="submit">Kunde anlegen & provisionieren</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/admin" />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen, erster Admin-User, alle Module, Mandanten-Einstellungen und der TISAX-Default (idempotent, protokolliert).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Kunde</TableHead>
|
||||
<TableHead>Kürzel</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Nutzer</TableHead>
|
||||
<TableHead>Aktive Module</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenants.map((t) => {
|
||||
const active = t.modules.filter((m) => m.enabled).length;
|
||||
return (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell>
|
||||
<Link href={`/admin/${t.id}`} className="font-bold hover:underline">{t.name}</Link>
|
||||
{t.sector && <div className="text-xs text-muted-foreground">{t.sector}</div>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.slug}</TableCell>
|
||||
<TableCell><Pill tone={STATUS_TONE[t.status]}>{STATUS_LABEL[t.status]}</Pill></TableCell>
|
||||
<TableCell className="text-muted-foreground">{t._count.users}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{active > 0 ? `${active} Module` : "—"}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* IM-D: E-Mail-Eingang für Vorfälle — Provisionierungsstatus + Review-Queue. */}
|
||||
{(pendingIntake.length > 0 || openReviews.length > 0) && (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Siren className="size-4 text-[var(--primary)]" />
|
||||
<p className="font-heading text-sm font-semibold">E-Mail-Eingang für Vorfälle</p>
|
||||
</div>
|
||||
|
||||
{pendingIntake.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Weiterleitung ausstehend ({pendingIntake.length})
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{pendingIntake.map((p) => (
|
||||
<li key={p.tenantId} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<Pill tone="warn">ausstehend</Pill>
|
||||
<Link href={`/admin/${p.tenantId}`} className="font-semibold hover:underline">
|
||||
{pendingTenantNames.get(p.tenantId) ?? p.tenantId}
|
||||
</Link>
|
||||
<code className="font-mono text-muted-foreground">{intakeAddress(p.token)}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
Der Status wechselt automatisch auf „verifiziert“, sobald die erste weitergeleitete Test-Mail als Vorfall ankommt.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{openReviews.length > 0 && (
|
||||
<div className="border-t pt-3">
|
||||
<p className="mb-1.5 text-[12.5px] font-semibold text-muted-foreground">
|
||||
Zu prüfende Eingänge ({openReviews.length})
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{openReviews.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-2 rounded-lg border bg-muted/30 px-3 py-2 text-[12px]">
|
||||
<Pill tone="mut">{REVIEW_REASON_LABEL[r.reason] ?? r.reason}</Pill>
|
||||
<span className="font-mono">{r.sender}</span>
|
||||
<span className="text-muted-foreground">{r.subject || "(ohne Betreff)"}</span>
|
||||
{r.recipient && <span className="text-muted-foreground">→ {r.recipient}</span>}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<form action={resolveInboundReview.bind(null, r.id, "zugeordnet")}>
|
||||
<Button type="submit" variant="outline" size="sm">Zugeordnet</Button>
|
||||
</form>
|
||||
<form action={resolveInboundReview.bind(null, r.id, "erledigt")}>
|
||||
<Button type="submit" variant="ghost" size="sm">Erledigt</Button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MailStatusPanel status={mailStatus} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/server/db";
|
||||
import { requirePlatformSession } from "@/server/platform-auth";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { CreatePlatformAdminForm, ResetPlatformAdminPasswordForm } from "@/components/platform-admin-forms";
|
||||
import { setPlatformAdminRole, setPlatformAdminStatus } from "@/server/actions/platform-admins";
|
||||
|
||||
/**
|
||||
* SEC4: Verwaltung der Plattform-Administratoren. Zugriff (Plattform-Session + MFA-Pflicht)
|
||||
* wird im (platform)/layout erzwungen. Verwalten dürfen nur Voll-Admins; Read-only sieht nur.
|
||||
*/
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "mut"> = { ACTIVE: "ok", LOCKED: "warn", DISABLED: "mut" };
|
||||
const STATUS_LABEL: Record<string, string> = { ACTIVE: "Aktiv", LOCKED: "Gesperrt", DISABLED: "Deaktiviert" };
|
||||
|
||||
export default async function PlatformAdminsPage() {
|
||||
const session = await requirePlatformSession();
|
||||
const me = await prisma.platformAdmin.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { id: true, role: true, mfaEnrolledAt: true },
|
||||
});
|
||||
const isFull = me?.role === "full";
|
||||
const actingMfa = !!me?.mfaEnrolledAt;
|
||||
const admins = await prisma.platformAdmin.findMany({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true, email: true, name: true, role: true, status: true, mfaEnrolledAt: true, lastLoginAt: true },
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform-Betrieb"
|
||||
title="Plattform-Administratoren"
|
||||
sub={`${admins.length} Konten · Ihre Rolle: ${isFull ? "Voll-Admin" : "Read-only"}`}
|
||||
actions={<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/admin" />}>Mandanten</Button>}
|
||||
/>
|
||||
{!isFull && <p className="mt-4 text-[12.5px] text-muted-foreground">Read-only-Zugriff: Sie können Admins ansehen, aber nicht verwalten.</p>}
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{admins.map((a) => (
|
||||
<div key={a.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">{a.name}{a.id === me?.id && <span className="text-muted-foreground"> (Sie)</span>}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">{a.email} · letzter Login {a.lastLoginAt ? a.lastLoginAt.toLocaleDateString("de-DE") : "—"}</p>
|
||||
</div>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Pill tone={a.role === "full" ? "info" : "mut"}>{a.role === "full" ? "Voll-Admin" : "Read-only"}</Pill>
|
||||
<Pill tone={STATUS_TONE[a.status] ?? "mut"}>{STATUS_LABEL[a.status] ?? a.status}</Pill>
|
||||
{a.mfaEnrolledAt && <Pill tone="ok">MFA</Pill>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isFull && (
|
||||
<div className="mt-3 flex flex-wrap items-end gap-2 border-t pt-3">
|
||||
<form action={setPlatformAdminRole.bind(null, a.id, a.role === "full" ? "readonly" : "full")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">{a.role === "full" ? "→ Read-only" : "→ Voll-Admin"}</Button>
|
||||
</form>
|
||||
{a.status === "ACTIVE" ? (
|
||||
<>
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "LOCKED")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Sperren</Button>
|
||||
</form>
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "DISABLED")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Deaktivieren</Button>
|
||||
</form>
|
||||
</>
|
||||
) : (
|
||||
<form action={setPlatformAdminStatus.bind(null, a.id, "ACTIVE")} className="flex items-end gap-1.5">
|
||||
{actingMfa && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA" className="h-8 w-24" required />}
|
||||
<Button type="submit" size="sm" variant="outline">Reaktivieren</Button>
|
||||
</form>
|
||||
)}
|
||||
<ResetPlatformAdminPasswordForm adminId={a.id} mfaEnrolled={actingMfa} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isFull && (
|
||||
<div className="shadow-card mt-6 max-w-2xl rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Neuen Plattform-Admin anlegen</p>
|
||||
<p className="mb-3 text-[12px] text-muted-foreground">Read-only-Admins können ansehen, aber nicht verändern. Voll-Admins verwalten Mandanten und Admins.</p>
|
||||
<CreatePlatformAdminForm mfaEnrolled={actingMfa} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { getPlatformSettings } from "@/server/platform-settings";
|
||||
import { platformSignOutAction } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
import { BRAND } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* Shell des Plattform-Betriebsbereichs (Phase-1-Härtung Paket 2). Zugriff nur mit
|
||||
* Plattform-Session (getrennte Auth-Domäne, kein Mandantenkontext) und aktivierter
|
||||
* MFA. Enthält bewusst KEINE Mandanten-Navigation — Plattform-Admins haben keinen
|
||||
* Zugriff auf Kundenfachdaten.
|
||||
*/
|
||||
export default async function PlatformLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
|
||||
const [admin, settings] = await Promise.all([
|
||||
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
|
||||
getPlatformSettings(),
|
||||
]);
|
||||
if (!admin || admin.status !== "ACTIVE") redirect("/platform/login");
|
||||
// MFA ist optional (Paket C) — Enrollment nur erzwingen, wenn die Plattform-Policy es verlangt.
|
||||
if (settings.mfaRequired && !admin.mfaEnrolledAt) redirect("/platform/enroll-mfa");
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="flex items-center justify-between border-b bg-[var(--panel)] px-6 py-3">
|
||||
<Link href="/admin" className="flex items-center gap-2.5 font-heading text-sm font-semibold">
|
||||
<CertviaLogo variant="mark" theme="dark" height={22} />
|
||||
{BRAND.name} · Plattform-Administration
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<Link href="/templates" className="text-muted-foreground hover:text-foreground">Vorlagen</Link>
|
||||
<Link href="/admin/backup" className="text-muted-foreground hover:text-foreground">Backup-Ziel</Link>
|
||||
<span className="text-muted-foreground">
|
||||
{admin.name} · {admin.email}
|
||||
</span>
|
||||
<Link href="/profile" className="text-muted-foreground hover:text-foreground">Profil & Sicherheit</Link>
|
||||
<form action={platformSignOutAction}>
|
||||
<Button type="submit" variant="ghost" size="sm">Abmelden</Button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { platformAuth } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { getPlatformSettings } from "@/server/platform-settings";
|
||||
import { disablePlatformMfa, setPlatformMfaRequired } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { ChangeEmailForm, ChangePasswordSelfForm } from "@/components/auth-recovery-forms";
|
||||
import { describePasswordPolicy, resolvePasswordPolicy } from "@/lib/password-policy";
|
||||
|
||||
/**
|
||||
* Profil & Sicherheit des Plattform-Admins (Paket C): MFA ist optional und kann hier
|
||||
* freiwillig aktiviert/deaktiviert werden. Zusätzlich lässt sich die plattformweite
|
||||
* MFA-Pflicht (Policy-Flag) schalten, um die Erzwingung wiederherzustellen.
|
||||
*/
|
||||
export default async function PlatformProfilePage() {
|
||||
const session = await platformAuth();
|
||||
if (!session?.user?.id) redirect("/platform/login");
|
||||
const [admin, settings] = await Promise.all([
|
||||
prisma.platformAdmin.findUnique({ where: { id: session.user.id } }),
|
||||
getPlatformSettings(),
|
||||
]);
|
||||
if (!admin) redirect("/platform/login");
|
||||
|
||||
// SEC2: Plattform-Admins unterliegen der Standard-Passwort-Policy (kein Mandant).
|
||||
const policyHint = describePasswordPolicy(resolvePasswordPolicy(undefined));
|
||||
const enrolled = !!admin.mfaEnrolledAt;
|
||||
const recoveryLeft = Array.isArray(admin.recoveryCodes) ? admin.recoveryCodes.length : 0;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb="Plattform-Betrieb" title="Profil & Sicherheit" sub={`${admin.name} · ${admin.email}`} />
|
||||
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-2">
|
||||
{/* MFA (persönlich) */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Zwei-Faktor-Authentifizierung</p>
|
||||
<Pill tone={enrolled ? "ok" : "mut"}>{enrolled ? "Aktiv" : "Inaktiv"}</Pill>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
MFA per TOTP ist {settings.mfaRequired ? "durch die Plattform-Policy verpflichtend" : "optional"}. Empfohlen für Betreiberzugänge.
|
||||
</p>
|
||||
{enrolled ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<p className="text-[12px] text-muted-foreground">Verbleibende Recovery-Codes: {recoveryLeft}</p>
|
||||
{/* Re-Authentifizierung (F-08): aktueller TOTP-Code zum Deaktivieren nötig. */}
|
||||
<form action={disablePlatformMfa} className="flex items-center gap-2">
|
||||
<input
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="6-stelliger Code"
|
||||
className="h-8 w-36 rounded-md border bg-background px-2 text-sm"
|
||||
disabled={settings.mfaRequired}
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={settings.mfaRequired}>MFA deaktivieren</Button>
|
||||
</form>
|
||||
{settings.mfaRequired && <p className="text-[11px] text-muted-foreground">Deaktivieren nicht möglich, solange die MFA-Pflicht aktiv ist.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<Button nativeButton={false} render={<Link href="/platform/enroll-mfa" />} size="sm">MFA aktivieren</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plattform-Policy: MFA-Pflicht */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Plattform-Policy: MFA-Pflicht</p>
|
||||
<Pill tone={settings.mfaRequired ? "ok" : "mut"}>{settings.mfaRequired ? "Erzwungen" : "Aus"}</Pill>
|
||||
</div>
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Ist die Pflicht aktiv, müssen alle Plattform-Admins MFA einrichten (Enrollment wird beim Zugriff erzwungen). Default: aus.
|
||||
</p>
|
||||
<form action={setPlatformMfaRequired.bind(null, !settings.mfaRequired)} className="mt-3">
|
||||
<Button type="submit" variant="outline" size="sm">
|
||||
{settings.mfaRequired ? "MFA-Pflicht deaktivieren" : "MFA-Pflicht aktivieren"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* SEC2: Passwort selbst aendern — meldet andere Sitzungen ab, behaelt die aktuelle. */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">Passwort ändern</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">
|
||||
Nach der Änderung werden alle anderen Sitzungen abgemeldet; diese bleibt bestehen.
|
||||
</p>
|
||||
<ChangePasswordSelfForm domain="platform" policyHint={policyHint} />
|
||||
</div>
|
||||
|
||||
{/* SEC2: E-Mail-Aenderung mit Bestaetigung der neuen Adresse. */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-5">
|
||||
<p className="mb-1 font-heading text-sm font-semibold">E-Mail-Adresse ändern</p>
|
||||
<p className="mb-3 text-[12.5px] text-muted-foreground">
|
||||
Aktuell: {admin.email}. Die Adresse ist zugleich der Anmeldename am Betreiberzugang.
|
||||
</p>
|
||||
<ChangeEmailForm domain="platform" currentEmail={admin.email} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PolicyExpertEditor } from "@/components/policy-expert-editor";
|
||||
import { updateTemplateDoc } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Dokument-Editor (Plattform, Phase 2). Bearbeitet ein Dokument im ENTWURF
|
||||
* (rawMarkdown, wie im Mandanten-Experten-Editor). Veröffentlichte Versionen: nur Lesen.
|
||||
*/
|
||||
export default async function TemplateDocEditor({ params, searchParams }: { params: Promise<{ locale: string; code: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const code = decodeURIComponent(p.code);
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const doc = await prisma.policyTemplateDoc.findUnique({ where: { versionId_locale_code: { versionId: version.id, locale, code } } });
|
||||
if (!doc) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
|
||||
const [vars, docs] = await Promise.all([
|
||||
prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { key: true, title: true } }),
|
||||
prisma.policyTemplateDoc.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" }, select: { code: true, title: true } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title={`${doc.code} — ${doc.title}`}
|
||||
sub={editable
|
||||
? "Entwurf bearbeiten. Fehlende {{Variablen}} werden beim Speichern angelegt."
|
||||
: "Veröffentlichte Version — nur Lesen. Zum Bearbeiten einen Entwurf anlegen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
{editable && <Button type="submit" form="tpl-doc" size="sm">Speichern</Button>}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
{editable ? (
|
||||
<PolicyExpertEditor
|
||||
formId="tpl-doc"
|
||||
saveAction={updateTemplateDoc.bind(null, doc.id)}
|
||||
initialMarkdown={doc.rawMarkdown}
|
||||
variables={vars}
|
||||
docs={docs}
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
readOnly
|
||||
value={doc.rawMarkdown}
|
||||
rows={32}
|
||||
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { upsertTemplateRequirement, deleteTemplateRequirement } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Anforderungen (Control-Mapping) je Sprache. Bearbeitbar nur im Entwurf.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
|
||||
export default async function TemplateRequirementsPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
const reqs = await prisma.policyTemplateRequirement.findMany({ where: { versionId: version.id, locale }, orderBy: { orderIdx: "asc" } });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title="Anforderungen (Control-Mapping)"
|
||||
sub={editable ? "Entwurf bearbeiten — Anforderungen je Control." : "Veröffentlichte Version — nur Lesen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{reqs.length} Anforderungen</p>
|
||||
{reqs.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Anforderungen in dieser Sprache.</p>}
|
||||
<div className="space-y-1.5">
|
||||
{reqs.map((r) => (
|
||||
<details key={r.id} className="rounded-lg border">
|
||||
<summary className="flex cursor-pointer items-center gap-3 px-3 py-2 text-[13px]">
|
||||
<span className="font-mono text-[12px]">{r.reqId}</span>
|
||||
<span className="text-muted-foreground">{r.control}</span>
|
||||
<span className="truncate">{r.requirement}</span>
|
||||
<Pill tone={r.obligation === "MUSS" ? "warn" : "mut"}>{r.obligation}</Pill>
|
||||
</summary>
|
||||
{editable ? (
|
||||
<div className="border-t p-3">
|
||||
<form action={upsertTemplateRequirement} className="grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" defaultValue={r.control} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Pflicht
|
||||
<select name="obligation" defaultValue={r.obligation} className={inp}><option>MUSS</option><option>SOLL</option></select>
|
||||
</label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" defaultValue={r.policyCode} className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Bedingung (Flag)<input name="condition" defaultValue={r.condition ?? ""} className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" defaultValue={r.requirement} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" defaultValue={r.implementation} rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground">Verfahren (VA, Komma)<input name="vaCodes" defaultValue={r.vaCodes.join(", ")} className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground">Nachweis-Link<input name="nachweisLink" defaultValue={r.nachweisLink ?? ""} className={inp} /></label>
|
||||
<input type="hidden" name="reqId" value={r.reqId} />
|
||||
<div className="col-span-2 mt-1 flex items-center gap-2 lg:col-span-4">
|
||||
<Button type="submit" size="sm">Speichern</Button>
|
||||
</div>
|
||||
</form>
|
||||
<form action={deleteTemplateRequirement.bind(null, r.id)} className="mt-2 border-t pt-2">
|
||||
<Button type="submit" variant="destructive" size="sm"><Trash2 className="mr-1 size-3.5" />Anforderung löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="border-t p-3 text-[12.5px]">
|
||||
<p className="mb-1"><span className="text-muted-foreground">Anforderung:</span> {r.requirement}</p>
|
||||
<p><span className="text-muted-foreground">Umsetzung:</span> {r.implementation}</p>
|
||||
</div>
|
||||
)}
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<form action={upsertTemplateRequirement} className="mt-4 grid grid-cols-2 gap-2 border-t pt-4 lg:grid-cols-4">
|
||||
<p className="col-span-2 font-heading text-[13px] font-semibold lg:col-span-4">Neue Anforderung</p>
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="text-[11.5px] text-muted-foreground">Anforderungs-ID<input name="reqId" required placeholder="4.1.2-M1" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Control<input name="control" placeholder="4.1.2" className={inp} /></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Pflicht<select name="obligation" className={inp}><option>MUSS</option><option>SOLL</option></select></label>
|
||||
<label className="text-[11.5px] text-muted-foreground">Richtlinie (policyCode)<input name="policyCode" placeholder="R08" className={inp} /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Anforderung<textarea name="requirement" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<label className="col-span-2 text-[11.5px] text-muted-foreground lg:col-span-4">Umsetzung<textarea name="implementation" rows={2} className="w-full rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" /></label>
|
||||
<div className="col-span-2 lg:col-span-4"><Button type="submit" size="sm">Anforderung anlegen</Button></div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { ArrowLeft, Trash2 } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { upsertTemplateVariable, deleteTemplateVariable } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Vorlagen-Variablen ({{Platzhalter}} / Feature-Flags) je Sprache. Bearbeitbar nur im Entwurf.
|
||||
*/
|
||||
const inp = "h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]";
|
||||
|
||||
export default async function TemplateVariablesPage({ params, searchParams }: { params: Promise<{ locale: string }>; searchParams: Promise<{ framework?: string }> }) {
|
||||
const p = await params;
|
||||
const locale = p.locale === "en" ? "en" : "de";
|
||||
const framework = (await searchParams).framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const version = draft ?? published;
|
||||
if (!version) notFound();
|
||||
const editable = version.status === "DRAFT";
|
||||
const vars = await prisma.policyTemplateVariable.findMany({ where: { versionId: version.id, locale }, orderBy: [{ groupName: "asc" }, { orderIdx: "asc" }] });
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb={`Vorlagen · ${locale.toUpperCase()}`}
|
||||
title="Variablen"
|
||||
sub={editable ? "Entwurf bearbeiten — Platzhalter und Feature-Flags." : "Veröffentlichte Version — nur Lesen."}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{editable ? <Pill tone="warn">Entwurf</Pill> : <Pill tone="ok">Veröffentlicht</Pill>}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates?locale=${locale}&framework=${framework}`} />}>
|
||||
<ArrowLeft className="mr-1 size-3.5" />Zurück
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-4">
|
||||
<p className="mb-3 font-heading text-sm font-semibold">{vars.length} Variablen</p>
|
||||
{vars.length === 0 && <p className="text-[13px] text-muted-foreground">Keine Variablen in dieser Sprache.</p>}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Key</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Titel</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Typ</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Gruppe</th>
|
||||
<th className="py-1.5 pr-2 text-left font-medium">Default</th>
|
||||
{editable && <th className="py-1.5 pr-2"></th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{vars.map((v) => (
|
||||
<tr key={v.id}>
|
||||
{editable ? (
|
||||
<>
|
||||
<td className="py-1 pr-2 font-mono text-[12px] align-middle">{v.key}</td>
|
||||
<td className="py-1 pr-2" colSpan={4}>
|
||||
<form action={upsertTemplateVariable} className="flex flex-wrap items-center gap-1.5">
|
||||
<input type="hidden" name="id" value={v.id} />
|
||||
<input name="title" defaultValue={v.title} className={inp + " w-40"} />
|
||||
<select name="kind" defaultValue={v.kind} className={inp + " w-24"}><option value="string">string</option><option value="boolean">boolean</option></select>
|
||||
<input name="groupName" defaultValue={v.groupName ?? ""} className={inp + " w-32"} placeholder="Gruppe" />
|
||||
<input name="value" defaultValue={v.value} className={inp + " w-32"} placeholder="Default" />
|
||||
<label className="flex items-center gap-1 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" defaultChecked={v.required} />Pflicht</label>
|
||||
<Button type="submit" size="xs">Speichern</Button>
|
||||
</form>
|
||||
</td>
|
||||
<td className="py-1 pr-2 text-right align-middle">
|
||||
<form action={deleteTemplateVariable.bind(null, v.id)}>
|
||||
<Button type="submit" variant="ghost" size="icon-xs" title="Löschen"><Trash2 className="size-3.5" /></Button>
|
||||
</form>
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.key}</td>
|
||||
<td className="py-1.5 pr-2">{v.title}</td>
|
||||
<td className="py-1.5 pr-2 text-muted-foreground">{v.kind}</td>
|
||||
<td className="py-1.5 pr-2 text-muted-foreground">{v.groupName ?? "—"}</td>
|
||||
<td className="py-1.5 pr-2 font-mono text-[12px]">{v.value || "—"}</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{editable && (
|
||||
<form action={upsertTemplateVariable} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<p className="w-full font-heading text-[13px] font-semibold">Neue Variable</p>
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Key<input name="key" required placeholder="PW_MIN_LENGTH" className={inp + " w-44"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Titel<input name="title" className={inp + " w-44"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Typ<select name="kind" className={inp + " w-28"}><option value="string">string</option><option value="boolean">boolean</option></select></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Gruppe<input name="groupName" className={inp + " w-36"} /></label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">Default<input name="value" className={inp + " w-32"} /></label>
|
||||
<label className="flex items-center gap-1 pb-1.5 text-[11.5px] text-muted-foreground"><input type="checkbox" name="required" />Pflicht</label>
|
||||
<Button type="submit" size="sm">Variable anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import Link from "next/link";
|
||||
import { FileText, Plus, Trash2, Pencil, Rocket } from "lucide-react";
|
||||
import { prisma } from "@/server/db";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { startDraftAction, discardDraft, createTemplateDoc, publishDraft } from "@/server/actions/policy-templates";
|
||||
|
||||
/**
|
||||
* Richtlinien-Vorlagen (Plattform, Phase 2). Bearbeitung des aktuellen ENTWURFS
|
||||
* (DRAFT); veröffentlichte Versionen sind unveränderlich. Sprache (de|en) per ?locale.
|
||||
* Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
|
||||
*/
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie", RICHTLINIE: "Richtlinie", VERFAHREN: "Verfahren",
|
||||
REGISTER: "Register", HANDBUCH: "Handbuch", EIGENES: "Eigenes",
|
||||
};
|
||||
const LOCALE_LABEL: Record<string, string> = { de: "Deutsch", en: "English" };
|
||||
const FRAMEWORK_LABEL: Record<string, string> = { TISAX: "TISAX / VDA ISA", ISO_27001: "ISO/IEC 27001" };
|
||||
|
||||
export default async function TemplatesPage({ searchParams }: { searchParams: Promise<{ locale?: string; framework?: string }> }) {
|
||||
const sp = await searchParams;
|
||||
const locale = sp.locale === "en" ? "en" : "de";
|
||||
// Editor-Framework (Default TISAX). ISO-Vorlagen sind jetzt genauso editier-/
|
||||
// versionierbar wie TISAX — nur im jeweils gewählten Namensraum.
|
||||
const framework = sp.framework === "ISO_27001" ? "ISO_27001" : "TISAX";
|
||||
|
||||
const [draft, published] = await Promise.all([
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "DRAFT", framework }, orderBy: { createdAt: "desc" } }),
|
||||
prisma.policyTemplateVersion.findFirst({ where: { status: "PUBLISHED", framework }, orderBy: [{ publishedAt: "desc" }, { createdAt: "desc" }] }),
|
||||
]);
|
||||
const activeVersion = draft ?? published;
|
||||
const editable = !!draft;
|
||||
|
||||
const docs = activeVersion
|
||||
? await prisma.policyTemplateDoc.findMany({ where: { versionId: activeVersion.id, locale }, orderBy: { orderIdx: "asc" } })
|
||||
: [];
|
||||
|
||||
const tab = (l: string) => (
|
||||
<Button
|
||||
key={l}
|
||||
variant={l === locale ? "default" : "outline"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/templates?locale=${l}&framework=${framework}`} />}
|
||||
>
|
||||
{LOCALE_LABEL[l]}
|
||||
</Button>
|
||||
);
|
||||
const fwTab = (f: "TISAX" | "ISO_27001") => (
|
||||
<Button
|
||||
key={f}
|
||||
variant={f === framework ? "default" : "outline"}
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/templates?locale=${locale}&framework=${f}`} />}
|
||||
>
|
||||
{FRAMEWORK_LABEL[f]}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead
|
||||
crumb="Plattform"
|
||||
title="Richtlinien-Vorlagen"
|
||||
sub="Master-Vorlagen bearbeiten und versionieren. Neue Mandanten erhalten die veröffentlichte Version automatisch; bestehende werden informiert und übernehmen selbst."
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1">{(["TISAX", "ISO_27001"] as const).map(fwTab)}</div>
|
||||
<span className="mx-1 h-5 w-px bg-border" />
|
||||
<div className="flex gap-1">{["de", "en"].map(tab)}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{!activeVersion ? (
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card p-6 text-[13px] text-muted-foreground">
|
||||
Es sind noch keine Vorlagen in der Datenbank. Bitte einmalig den Sync ausführen:
|
||||
<code className="mx-1 rounded bg-[var(--elevated)] px-1.5 py-0.5">npx tsx scripts/sync-policy-templates.ts</code>
|
||||
(überführt das Datei-Paket als veröffentlichte Version).
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[320px_1fr]">
|
||||
{/* Version / Entwurf */}
|
||||
<div className="shadow-card h-fit rounded-xl border bg-card p-4 text-[13px]">
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Version</p>
|
||||
<dl className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Rahmenwerk</dt><dd className="font-medium">{FRAMEWORK_LABEL[framework]}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Veröffentlicht</dt><dd className="font-medium">{published?.version ?? "—"}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Entwurf</dt><dd className="font-medium">{draft?.version ?? "—"}</dd></div>
|
||||
<div className="flex justify-between gap-2"><dt className="text-muted-foreground">Sprache</dt><dd>{LOCALE_LABEL[locale]}</dd></div>
|
||||
</dl>
|
||||
<div className="mt-3">
|
||||
{editable
|
||||
? <Pill tone="warn">Entwurf bearbeitbar</Pill>
|
||||
: <Pill tone="ok">Nur Lesen (veröffentlicht)</Pill>}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-col gap-2 border-t pt-3">
|
||||
{!draft ? (
|
||||
<form action={startDraftAction.bind(null, framework)}>
|
||||
<Button type="submit" className="w-full"><Pencil className="mr-1.5 size-4" />Entwurf anlegen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<form action={publishDraft} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
|
||||
Versionsnummer
|
||||
<input name="version" defaultValue={draft.version} className="h-8 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[11.5px] text-muted-foreground">
|
||||
Änderungshinweis (für Mandanten)
|
||||
<textarea name="notes" rows={2} placeholder="Was ändert sich?" className="rounded-md border border-input bg-transparent px-2 py-1 text-[12.5px]" />
|
||||
</label>
|
||||
<Button type="submit" className="w-full"><Rocket className="mr-1.5 size-4" />Veröffentlichen</Button>
|
||||
</form>
|
||||
<p className="text-[11.5px] text-muted-foreground">Neue Mandanten erhalten die Version automatisch; bestehende sehen ein Update und übernehmen selbst (nicht-destruktiv).</p>
|
||||
<form action={discardDraft.bind(null, framework)}>
|
||||
<Button type="submit" variant="outline" size="sm" className="w-full"><Trash2 className="mr-1.5 size-4" />Entwurf verwerfen</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-3 flex flex-col gap-1.5 border-t pt-3 text-[12.5px]">
|
||||
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/requirements?framework=${framework}`}>Anforderungen (Control-Mapping) →</Link>
|
||||
<Link className="text-muted-foreground hover:text-foreground" href={`/templates/${locale}/variables?framework=${framework}`}>Variablen →</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dokumente */}
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<p className="font-heading text-sm font-semibold">Dokumente ({docs.length})</p>
|
||||
</div>
|
||||
|
||||
{docs.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
Keine Dokumente in dieser Sprache. {locale === "en" && "Englische Inhalte müssen angelegt werden — "}
|
||||
{editable ? "Neues Dokument unten anlegen." : "Zum Bearbeiten zuerst einen Entwurf anlegen."}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="border-b"><th className="py-1.5 pr-3 text-left font-medium">Code</th><th className="py-1.5 pr-3 text-left font-medium">Titel</th><th className="py-1.5 pr-3 text-left font-medium">Typ</th><th className="py-1.5 pr-3"></th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{docs.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td className="py-1.5 pr-3 font-mono text-[12px]">{d.code}</td>
|
||||
<td className="py-1.5 pr-3">{d.title}</td>
|
||||
<td className="py-1.5 pr-3 text-muted-foreground">{TYPE_LABEL[d.type] ?? d.type}</td>
|
||||
<td className="py-1.5 pr-3 text-right">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/templates/${locale}/${encodeURIComponent(d.code)}?framework=${framework}`} />}>
|
||||
<FileText className="mr-1 size-3.5" />{editable ? "Bearbeiten" : "Ansehen"}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<form action={createTemplateDoc} className="mt-4 flex flex-wrap items-end gap-2 border-t pt-4">
|
||||
<input type="hidden" name="locale" value={locale} />
|
||||
<input type="hidden" name="framework" value={framework} />
|
||||
<label className="flex flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Code</span>
|
||||
<input name="code" required placeholder="R15 / VA-21 / EIG-1" className="h-9 w-36 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Typ</span>
|
||||
<select name="type" className="h-9 rounded-md border border-input bg-transparent px-2 text-[13px]">
|
||||
<option value="RICHTLINIE">Richtlinie</option>
|
||||
<option value="VERFAHREN">Verfahren</option>
|
||||
<option value="LEITLINIE">Leitlinie</option>
|
||||
<option value="REGISTER">Register</option>
|
||||
<option value="EIGENES">Eigenes</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-1 flex-col gap-1 text-[12px]">
|
||||
<span className="text-muted-foreground">Titel</span>
|
||||
<input name="title" required placeholder="Titel des Dokuments" className="h-9 w-full min-w-40 rounded-md border border-input bg-transparent px-2 text-[13px]" />
|
||||
</label>
|
||||
<Button type="submit" size="sm"><Plus className="mr-1 size-4" />Dokument anlegen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/server/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { platformHandlers } from "@/server/platform-auth";
|
||||
|
||||
export const { GET, POST } = platformHandlers;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { requirePlatformFullAdmin } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { exportTenant } from "@/server/backup/export";
|
||||
import { writePlatformAudit } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* Direkt-Download einer Mandanten-Sicherung auf den Rechner des Betreibers.
|
||||
*
|
||||
* Anders als der asynchrone Portal-Export (Worker → Objektspeicher) läuft der
|
||||
* Export hier INLINE (`persist: false` → kein Store, kein Redis/Worker) und wird
|
||||
* als verschlüsselte `.cvb`-Datei sofort in den Browser gestreamt. Das ist der
|
||||
* „Lokal = auf den Rechner"-Fall und funktioniert auch ohne konfiguriertes S3.
|
||||
*
|
||||
* Kontrollen:
|
||||
* 1. Nur **Plattform-Voll-Admin** (`requirePlatformFullAdmin`) — der Handler
|
||||
* läuft NICHT durch das (platform)/layout-Gate, Auth also eigenständig.
|
||||
* 2. Der Mandant muss existieren; jeder Lauf wird auditiert (ohne PII).
|
||||
* 3. Auslieferung als `attachment` + `nosniff`, `Cache-Control: no-store`.
|
||||
*
|
||||
* Hinweis: Der Export ist eine Momentaufnahme in einer RepeatableRead-Transaktion;
|
||||
* bei sehr großen Mandanten kann er dauern (Request-Timeout beachten) — für den
|
||||
* regelmäßigen Serverschutz bleibt der asynchrone Export/der Objektspeicher.
|
||||
*/
|
||||
export async function GET(req: Request) {
|
||||
let adminId: string;
|
||||
try {
|
||||
const { admin } = await requirePlatformFullAdmin();
|
||||
adminId = admin.id;
|
||||
} catch {
|
||||
return new Response("Nicht berechtigt.", { status: 403 });
|
||||
}
|
||||
|
||||
const tenantId = new URL(req.url).searchParams.get("tenant")?.trim() ?? "";
|
||||
if (!tenantId) return new Response("Mandant fehlt.", { status: 400 });
|
||||
|
||||
const tenant = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { slug: true } });
|
||||
if (!tenant) return new Response("Nicht gefunden.", { status: 404 });
|
||||
|
||||
try {
|
||||
const result = await exportTenant(tenantId, { persist: false });
|
||||
await writePlatformAudit({
|
||||
actorId: adminId,
|
||||
action: "export",
|
||||
entity: "backup_download",
|
||||
entityId: tenantId,
|
||||
after: { snapshotId: result.snapshotId, bytes: result.artifact.length },
|
||||
});
|
||||
const filename = `${tenant.slug}-${result.snapshotId}.cvb`;
|
||||
return new Response(new Uint8Array(result.artifact), {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Content-Length": String(result.artifact.length),
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[backup] Direkt-Download fehlgeschlagen:", err);
|
||||
return new Response("Export fehlgeschlagen.", { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { requirePlatformSession } from "@/server/platform-auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { getBackupStore } from "@/server/storage/backup-store";
|
||||
import { writePlatformAudit } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* DSGVO-Zustellung: token-basierter, zeitlich begrenzter Download des vom Worker
|
||||
* erzeugten ZIP-Pakets (KONZEPT §5 „signierter, ablaufender Link").
|
||||
*
|
||||
* Kontrollen (mehrschichtig):
|
||||
* 1. Nur mit gültiger **Plattform-Session** (Betreiber-Bereich) — der Link ist
|
||||
* betreiber-intern (AVV-Unterstützung), nicht öffentlich.
|
||||
* 2. Das opake Token (`randomBytes(32)`) muss exakt eine `BackupJob`-Zeile
|
||||
* treffen; unbekannt → 404 (keine Existenz-Preisgabe).
|
||||
* 3. **TTL:** nach `downloadExpiresAt` wird 410 (Gone) geliefert — der Link
|
||||
* läuft ab (kurze Gültigkeit).
|
||||
*
|
||||
* Route-Handler laufen NICHT durch das Layout-Gate → Auth hier eigenständig.
|
||||
* Auslieferung als `attachment` + `nosniff`, `Cache-Control: no-store`.
|
||||
*/
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
// 1. Plattform-Session erforderlich.
|
||||
const session = await requirePlatformSession();
|
||||
|
||||
const { token } = await params;
|
||||
if (!token || token.length < 20) return new Response("Nicht gefunden.", { status: 404 });
|
||||
|
||||
// 2. Token → Job.
|
||||
const job = await prisma.backupJob.findUnique({
|
||||
where: { downloadToken: token },
|
||||
select: {
|
||||
id: true,
|
||||
kind: true,
|
||||
tenantId: true,
|
||||
status: true,
|
||||
downloadExpiresAt: true,
|
||||
result: true,
|
||||
},
|
||||
});
|
||||
if (!job || job.kind !== "dsgvo_export" || job.status !== "done") {
|
||||
return new Response("Nicht gefunden.", { status: 404 });
|
||||
}
|
||||
|
||||
// 3. TTL.
|
||||
if (!job.downloadExpiresAt || job.downloadExpiresAt.getTime() < Date.now()) {
|
||||
return new Response("Der Download-Link ist abgelaufen.", { status: 410 });
|
||||
}
|
||||
|
||||
const storageKey =
|
||||
job.result && typeof job.result === "object" && "storageKey" in job.result
|
||||
? String((job.result as { storageKey?: unknown }).storageKey ?? "")
|
||||
: "";
|
||||
// Defense in Depth: Key muss mandantenpräfixiert zum Job passen.
|
||||
if (!storageKey || !storageKey.startsWith(`${job.tenantId}/dsgvo-exports/`)) {
|
||||
return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
}
|
||||
|
||||
const bytes = await (await getBackupStore()).get(storageKey);
|
||||
if (!bytes) return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||
|
||||
await writePlatformAudit({
|
||||
actorId: session.user.id,
|
||||
action: "export",
|
||||
entity: "dsgvo_download",
|
||||
entityId: job.id,
|
||||
after: { tenantId: job.tenantId, bytes: bytes.length },
|
||||
});
|
||||
|
||||
const filename = `dsgvo-export-${job.id}.zip`;
|
||||
return new Response(new Uint8Array(bytes), {
|
||||
headers: {
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
"Content-Length": String(bytes.length),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth } from "@/server/auth";
|
||||
import { dbForTenant, prisma } from "@/server/db";
|
||||
import { resolvePasswordPolicy, describePasswordPolicy } from "@/lib/password-policy";
|
||||
import { ForcePasswordChangeForm } from "@/components/force-password-change-form";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
|
||||
/**
|
||||
* Erzwungener Passwortwechsel beim ersten Login (Force-Change). Liegt außerhalb der
|
||||
* (app)-Shell, damit deren Guard nicht in eine Weiterleitungsschleife läuft.
|
||||
*/
|
||||
export default async function ChangePasswordPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
// Option C (WS4): mustChangePassword gehört der GLOBALEN Identity.
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) redirect("/login");
|
||||
const [identity, settings] = await Promise.all([
|
||||
prisma.identity.findUnique({ where: { id: identityId }, select: { mustChangePassword: true } }),
|
||||
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
|
||||
]);
|
||||
// Wer keinen Wechsel (mehr) schuldet, wird zur App durchgereicht.
|
||||
if (!identity?.mustChangePassword) redirect("/dashboard");
|
||||
|
||||
const hint = describePasswordPolicy(resolvePasswordPolicy(settings?.securityPolicy));
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8">
|
||||
<CertviaLogo variant="lockup" theme="dark" height={34} />
|
||||
<p className="mt-5 font-heading text-lg font-semibold">Passwort ändern</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ihr Konto wurde mit einem Initialpasswort angelegt. Bitte vergeben Sie jetzt ein eigenes Passwort, um fortzufahren.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<ForcePasswordChangeForm policyHint={hint} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import QRCode from "qrcode";
|
||||
import { auth } from "@/server/auth";
|
||||
import { prisma } from "@/server/db";
|
||||
import { newTotpSecret, totpUri } from "@/server/mfa";
|
||||
import { encryptSecret, decryptSecret } from "@/server/secret-crypto";
|
||||
import { TenantMfaEnrollForm } from "@/components/tenant-mfa-enroll-form";
|
||||
import { PasskeyManager } from "@/components/passkey-manager";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
|
||||
/**
|
||||
* SEC3-a: Pflicht-MFA-Einrichtung für Mandanten-Nutzer, wenn der Mandant MFA verlangt
|
||||
* (securityPolicy.mfaRequired). Liegt bewusst außerhalb des (app)-Layouts, damit das
|
||||
* Enrollment-Gate keinen Redirect-Loop erzeugt. Erzeugt/persistiert ein Einrichtungs-
|
||||
* Secret, zeigt QR + Klartext und bestätigt per TOTP-Code (confirmOwnMfaEnrollment).
|
||||
*/
|
||||
export default async function EnrollMfaPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
|
||||
const identityId = session.user.identityId;
|
||||
if (!identityId) redirect("/login");
|
||||
// Option C (WS4/WS4b): MFA und Passkeys gehören der GLOBALEN Identity.
|
||||
const [identity, passkeyCount] = await Promise.all([
|
||||
prisma.identity.findUnique({ where: { id: identityId }, select: { email: true, mfaSecret: true, mfaEnrolledAt: true } }),
|
||||
prisma.webAuthnCredential.count({ where: { identityId } }),
|
||||
]);
|
||||
if (!identity) redirect("/login");
|
||||
// Zweiter Faktor bereits vorhanden (TOTP oder Passkey) → Gate erfüllt.
|
||||
if (identity.mfaEnrolledAt || passkeyCount > 0) redirect("/dashboard");
|
||||
|
||||
const secret = identity.mfaSecret ? decryptSecret(identity.mfaSecret) : newTotpSecret();
|
||||
if (!identity.mfaSecret) await prisma.identity.update({ where: { id: identityId }, data: { mfaSecret: encryptSecret(secret) } });
|
||||
const qr = await QRCode.toDataURL(totpUri(identity.email, secret), { margin: 1, width: 208 });
|
||||
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-md rounded-2xl border bg-card p-8">
|
||||
<CertviaLogo variant="lockup" theme="dark" height={34} />
|
||||
<p className="mt-5 font-heading text-lg font-semibold">Zwei-Faktor-Authentifizierung einrichten</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Ihre Organisation verlangt eine Zwei-Faktor-Authentifizierung. Scannen Sie den QR-Code mit
|
||||
einer Authenticator-App (z. B. Google Authenticator, Aegis, 1Password) und bestätigen Sie mit
|
||||
dem angezeigten Code.
|
||||
</p>
|
||||
|
||||
<div className="mt-5 flex flex-col items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={qr} alt="QR-Code für die Authenticator-App" width={208} height={208} className="rounded-lg border bg-white p-2" />
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-muted-foreground">Manuell eingeben:</p>
|
||||
<code className="select-all break-all font-mono text-xs">{secret}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<TenantMfaEnrollForm />
|
||||
</div>
|
||||
|
||||
<div className="my-5 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="h-px flex-1 bg-[var(--panel-brd)]" /> oder <span className="h-px flex-1 bg-[var(--panel-brd)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[12.5px] font-semibold">Passkey einrichten</p>
|
||||
<PasskeyManager credentials={[]} />
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-xs text-muted-foreground">
|
||||
Nach der Einrichtung <Link href="/dashboard" className="underline hover:text-foreground">zum Dashboard</Link>.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
import { PoweredByGefim } from "@/components/brand/powered-by-gefim";
|
||||
|
||||
/**
|
||||
* Gebrandete Fehlerseite (Story S7). Zeigt bewusst keine technischen Details —
|
||||
* nur die von Next.js vergebene `digest`, damit ein Vorfall im Server-Log
|
||||
* zuzuordnen ist.
|
||||
*/
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<main className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="shadow-card w-full max-w-sm rounded-2xl border bg-card p-8 text-center">
|
||||
<CertviaLogo variant="lockup" theme="dark" height={34} className="mx-auto" />
|
||||
<p className="mt-6 font-heading text-lg font-semibold">Es ist ein Fehler aufgetreten</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Die Aktion konnte nicht abgeschlossen werden. Bitte versuchen Sie es erneut oder wenden
|
||||
Sie sich an Ihre Administration.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="mt-3 font-mono text-[11px] text-muted-foreground">Referenz: {error.digest}</p>
|
||||
)}
|
||||
<Button onClick={reset} className="mt-6 w-full justify-center">
|
||||
Erneut versuchen
|
||||
</Button>
|
||||
<PoweredByGefim className="mt-7 border-t border-[var(--panel-brd)] pt-4" />
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user