Fundament: ISMS-Module entfernt; Craftvia-Rollen, Module, Navigation, i18n-Split

- ISMS-Routen, Actions, Server-/Lib-Code, Komponenten, Prisma-Modelle, Seeds,
  Importer, Skripte und ISMS-Tests entfernt (Fundament bleibt: Auth, Identity,
  MFA/WebAuthn, RBAC, Audit, Mail, Storage, Backup/DSGVO, Plattform-Admin)
- Schema auf Fundament-Modelle reduziert; TenantSettings generisch (+phone/email)
- TENANT_MODELS (db.ts, backup/topology.ts) und PII-Felder ausgedünnt
- RBAC: Rollen tenant-admin/backoffice/team-lead/technician + Craftvia-Permissions
- Modul-Katalog (customers, sites, teams, work_orders, imports, field, reports,
  emergency, documents, notifications, lotse) + Navigation aus src/lib/nav.ts
- Modul-Routen mit requireModule-Layout und Platzhalterseite
- Message-Katalog je Namespace (messages/<locale>/<namespace>.json), fs-Loader
- check-module-guards: Modul-Key aus src/server/actions/<moduleKey>/
- Provisionierung, Admin-Konsole, Einstellungen, Files-Route, Mail entkoppelt
- Seed minimal (demo/demo2, Nutzer je Rolle); Fundament-Tests auf Role/
  NotificationPreference-Fixtures umgestellt

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:35:44 +02:00
co-authored by Claude Opus 5
parent c8e6f30a27
commit 8491c7f173
443 changed files with 1325 additions and 87773 deletions
-11
View File
@@ -1,11 +0,0 @@
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}`);
}
-11
View File
@@ -1,11 +0,0 @@
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}`);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-6
View File
@@ -1,6 +0,0 @@
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");
}
-454
View File
@@ -1,454 +0,0 @@
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>
);
}
@@ -1,217 +0,0 @@
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>
);
}
@@ -1,90 +0,0 @@
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>
);
}
@@ -1,63 +0,0 @@
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>
);
}
@@ -1,206 +0,0 @@
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>
);
}
@@ -1,36 +0,0 @@
"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>
);
}
@@ -1,12 +0,0 @@
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`);
}
@@ -1,68 +0,0 @@
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>
);
}
@@ -1,44 +0,0 @@
"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>
);
}
@@ -1,186 +0,0 @@
"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 &amp; 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>
)}
</>
);
}
@@ -1,57 +0,0 @@
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}"`,
},
});
}
-16
View File
@@ -1,16 +0,0 @@
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}</>;
}
-181
View File
@@ -1,181 +0,0 @@
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>
);
}
@@ -1,112 +0,0 @@
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>
);
}
@@ -1,148 +0,0 @@
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 &amp; 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>
);
}
@@ -1,109 +0,0 @@
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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „customers": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("customers");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="customers" />;
}
+28 -187
View File
@@ -1,199 +1,40 @@
import Link from "next/link";
import { AlertTriangle, Compass, ListChecks } from "lucide-react";
import { getFormatter, getTranslations } from "next-intl/server";
import { LayoutDashboard } from "lucide-react";
import { 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";
import { PageHead } from "@/components/mockup-ui";
export default async function DashboardPage() {
/**
* Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze,
* freizugebende Berichte, Notdienst) liefern die Fachmodule.
*/
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ module?: string }>;
}) {
const session = await requireSession();
const sp = await searchParams;
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>
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })}
/>
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
</p>
)}
{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 className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5">
<LayoutDashboard className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" aria-hidden />
<div>
<p className="font-heading text-sm font-semibold">{t("placeholderTitle")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("placeholder")}</p>
</div>
</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>
);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-132
View File
@@ -1,132 +0,0 @@
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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „documents": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("documents");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="documents" />;
}
+12 -21
View File
@@ -1,24 +1,24 @@
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.
* Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys.
*
* 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.
* Mandanten-Isolation:
* Der Key ist mandantenpräfixiert (`<tenantId>/…`). Er MUSS mit dem Tenant der
* aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen
* (keine Existenz-Preisgabe).
*
* TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell
* existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz
* (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf
* (document:read bzw. document:read_internal für interne Dokumente).
*
* 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).
* über `requireSession` erzwungen.
*/
export async function GET(
_req: Request,
@@ -33,6 +33,7 @@ export async function GET(
// Pfad-Traversal ausschließen und Mandantenpräfix erzwingen.
if (
!tenantId ||
!key ||
key.includes("..") ||
key.includes("\0") ||
@@ -41,16 +42,6 @@ export async function GET(
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.
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „imports": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("imports");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="imports" />;
}
-97
View File
@@ -1,97 +0,0 @@
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 });
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-284
View File
@@ -1,284 +0,0 @@
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>
);
}
+31 -100
View File
@@ -1,54 +1,31 @@
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 { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
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";
import { CraftviaLogo } from "@/components/brand/craftvia-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).
// 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.
// abgemeldet, ein offener Passwortwechsel wird erzwungen. Membership-Status kommt vom
// User, die globalen Auth-Zustände (Passwortzwang, Kill-Switch, MFA) von der Identity.
const account = await dbForTenant(session.user.tenantId).user.findUnique({
where: { id: session.user.id },
select: { status: true },
@@ -67,7 +44,7 @@ export default async function AppLayout({
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" />
<CraftviaLogo variant="horizontal" 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">
@@ -94,27 +71,8 @@ export default async function AppLayout({
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)
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
// Seiten/Actions prüfen serverseitig selbst).
const [moduleRows, brandingSettings] = await Promise.all([
dbForTenant(session.user.tenantId).tenantModule.findMany(),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
@@ -122,22 +80,14 @@ export default async function AppLayout({
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 navItems = visibleNavItems(NAV_ITEMS, {
disabledModules,
permissions: session.user.permissions ?? [],
});
const mainNav = navItems.filter((i) => i.section === "main");
const adminNav = navItems.filter((i) => i.section === "admin");
const initials = (session.user.name ?? "?")
.split(/\s+/)
@@ -156,34 +106,26 @@ export default async function AppLayout({
<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} />
<TenantBrand branding={branding} height={34} />
</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>
)
)}
{mainNav.map((item) => (
<NavLink key={item.href} href={item.href}>
<item.icon className="size-[18px] opacity-85" />
{t(item.label)}
</NavLink>
))}
{canManageTenant && (
{adminNav.length > 0 && (
<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>
{adminNav.map((item) => (
<NavLink key={item.href} href={item.href}>
<item.icon className="size-[18px] opacity-85" />
{t(item.label)}
</NavLink>
))}
</div>
)}
</nav>
@@ -191,21 +133,10 @@ export default async function AppLayout({
<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>
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
<div className="flex-1" />
<UiLocaleSwitcher current={identity.uiLocale} />
<Link href="/account" className="flex items-center gap-3" title="Mein Konto (MFA)">
<Link href="/account" className="flex items-center gap-3">
<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>
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „field": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("field");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="field" />;
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „emergency": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("emergency");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="emergency" />;
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-133
View File
@@ -1,133 +0,0 @@
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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „notifications": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("notifications");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="notifications" />;
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-250
View File
@@ -1,250 +0,0 @@
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>
);
}
@@ -1,143 +0,0 @@
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 &amp; 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>
);
}
@@ -1,92 +0,0 @@
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>
);
}
@@ -1,201 +0,0 @@
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>
);
}
@@ -1,115 +0,0 @@
"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");
}
@@ -1,48 +0,0 @@
"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>
);
}
@@ -1,301 +0,0 @@
"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>
);
}
@@ -1,148 +0,0 @@
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>
);
}
@@ -1,257 +0,0 @@
"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>
);
}
@@ -1,117 +0,0 @@
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>
);
}
@@ -1,18 +0,0 @@
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>
);
}
@@ -1,139 +0,0 @@
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 &amp; 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 &amp; 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 &amp; 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>
);
}
@@ -1,287 +0,0 @@
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>
);
}
@@ -1,136 +0,0 @@
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>
);
}
@@ -1,130 +0,0 @@
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 &amp; 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>
);
}
@@ -1,93 +0,0 @@
"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>
)}
</>
);
}
@@ -1,179 +0,0 @@
"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>
);
}
@@ -1,179 +0,0 @@
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 &amp; 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 &amp; 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 &amp; 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>
);
}
@@ -1,107 +0,0 @@
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>
);
}
-220
View File
@@ -1,220 +0,0 @@
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>
);
}
-120
View File
@@ -1,120 +0,0 @@
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>
);
}
-127
View File
@@ -1,127 +0,0 @@
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>
);
}
-107
View File
@@ -1,107 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-389
View File
@@ -1,389 +0,0 @@
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>
);
}
-123
View File
@@ -1,123 +0,0 @@
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>
);
}
-56
View File
@@ -1,56 +0,0 @@
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 &amp; zuordnen</Button>
<Button variant="outline" nativeButton={false} render={<Link href="/policies" />}>Abbrechen</Button>
</div>
</form>
</main>
);
}
@@ -1,11 +0,0 @@
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}`);
}
-11
View File
@@ -1,11 +0,0 @@
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}`);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-6
View File
@@ -1,6 +0,0 @@
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");
}
-537
View File
@@ -1,537 +0,0 @@
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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „reports": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("reports");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="reports" />;
}
-234
View File
@@ -1,234 +0,0 @@
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 &amp; 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 &amp; 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>
);
}
-101
View File
@@ -1,101 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-290
View File
@@ -1,290 +0,0 @@
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>
);
}
@@ -1,117 +0,0 @@
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>
);
}
+27 -77
View File
@@ -6,14 +6,16 @@ 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 { ShieldCheck, History } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
// TODO(i18n): Texte dieser Seite in messages/<locale>/settings.json überführen
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
export default async function SettingsPage({
@@ -47,12 +49,13 @@ export default async function SettingsPage({
auditActors = Object.fromEntries(users.map((u) => [u.id, u.name]));
}
}
const enabled = new Set(moduleRows.filter((m) => m.enabled).map((m) => m.moduleKey));
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
const disabled = 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" />
<PageHead crumb="Organisation" title="Einstellungen" sub="Unternehmensdaten, Branding und Regionales" />
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{(hasPermission(session, "user:manage") || hasPermission(session, "role:manage")) && (
@@ -65,32 +68,7 @@ export default async function SettingsPage({
</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 &amp; 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) */}
{/* Audit-Trail (Aktivitätsprotokoll) — Popup (?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)]" />
@@ -120,10 +98,10 @@ export default async function SettingsPage({
{/* 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>
<p className="mb-3 text-[12px] text-muted-foreground">Erscheinen u. a. in Einsatzberichten und Benachrichtigungen.</p>
<div className="grid gap-4 md:grid-cols-2">
<div className="md:col-span-2">
<Label htmlFor="orgName">Unternehmensname *</Label>
<Label htmlFor="orgName">Firmenname *</Label>
<Input id="orgName" name="orgName" required defaultValue={v(s?.orgName)} className="mt-1" />
</div>
<div>
@@ -131,45 +109,21 @@ export default async function SettingsPage({
<Input id="orgShort" name="orgShort" defaultValue={v(s?.orgShort)} className="mt-1" />
</div>
<div>
<Label htmlFor="sector">Sektor</Label>
<Label htmlFor="sector">Gewerk / Branche</Label>
<Input id="sector" name="sector" defaultValue={v(s?.sector)} className="mt-1" />
</div>
<div>
<div className="md:col-span-2">
<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" />
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" type="tel" defaultValue={v(s?.phone)} className="mt-1" />
</div>
<div>
<Label htmlFor="ismsScope">ISMS-Geltungsbereich (Kurz)</Label>
<Input id="ismsScope" name="ismsScope" defaultValue={v(s?.ismsScope)} className="mt-1" />
<Label htmlFor="email">E-Mail</Label>
<Input id="email" name="email" type="email" defaultValue={v(s?.email)} 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>
@@ -177,32 +131,28 @@ export default async function SettingsPage({
<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-1 font-heading text-sm font-semibold">Branding &amp; 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="accent">Akzentfarbe (Hex)</Label><Input id="accent" name="accent" defaultValue={v(s?.accent)} placeholder="#082E5B" className="mt-1" /></div>
<div>
<Label htmlFor="locale">Sprache</Label>
<Label htmlFor="locale">Sprache (Benachrichtigungen/Dokumente)</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>
{/* Logo-Platzhalter: TenantSettings.logoKey (Upload in den Objektspeicher folgt). */}
<div>
<Label>Firmenlogo</Label>
<p className="mt-1 text-[11px] text-muted-foreground">
Logo-Upload (PNG/SVG) folgt. Bis dahin zeigt die Anwendung das {BRAND.name}-Logo.
</p>
</div>
</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>
@@ -212,7 +162,7 @@ export default async function SettingsPage({
<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>
<Pill key={m.key} tone={disabled.has(m.key) ? "mut" : "ok"}>{m.name}</Pill>
))}
</div>
<p className="mt-3 text-[11px] text-muted-foreground">Die Modul-Freischaltung erfolgt durch den Plattform-Betreiber.</p>
@@ -1,72 +0,0 @@
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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „sites": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("sites");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="sites" />;
}
-117
View File
@@ -1,117 +0,0 @@
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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"`,
},
});
}
-155
View File
@@ -1,155 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-327
View File
@@ -1,327 +0,0 @@
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>
);
}
-7
View File
@@ -1,7 +0,0 @@
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}</>;
}
-350
View File
@@ -1,350 +0,0 @@
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 &amp; ü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 &amp; 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>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „teams": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("teams");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="teams" />;
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „work_orders": deckt alle Unterrouten ab (deaktiviert ⇒ Redirect aufs Dashboard). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders");
return <>{children}</>;
}
+5
View File
@@ -0,0 +1,5 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
export default function Page() {
return <ModulePlaceholder moduleKey="work_orders" />;
}
+12 -168
View File
@@ -7,8 +7,7 @@ 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 { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
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";
@@ -20,9 +19,6 @@ 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" };
@@ -31,7 +27,7 @@ export default async function AdminTenantPage({
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 }>;
searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -52,10 +48,6 @@ export default async function AdminTenantPage({
});
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 }));
@@ -68,12 +60,6 @@ export default async function AdminTenantPage({
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 —
@@ -115,7 +101,7 @@ export default async function AdminTenantPage({
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,
// FAIL-SAFE: Ein nicht erreichbarer Sicherungsspeicher (S3/Garage 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[] = [];
@@ -170,7 +156,7 @@ export default async function AdminTenantPage({
<PageHead
crumb={t("crumb")}
title={tenant.name}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "", level: s?.tisaxLevel ?? "AL2" })}
sub={t("sub", { slug: tenant.slug, sector: tenant.sector ? ` · ${tenant.sector}` : "" })}
actions={<Pill tone={STATUS_TONE[tenant.status]}>{STATUS_LABEL[tenant.status]}</Pill>}
/>
</div>
@@ -188,9 +174,8 @@ export default async function AdminTenantPage({
<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("phone")} value={s?.phone} empty={t("notSet")} />
<Field label={t("email")} value={s?.email} empty={t("notSet")} />
<Field label={t("status")} value={STATUS_LABEL[tenant.status]} />
</dl>
</div>
@@ -226,20 +211,6 @@ export default async function AdminTenantPage({
<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>
@@ -262,55 +233,6 @@ export default async function AdminTenantPage({
<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>
@@ -325,16 +247,16 @@ export default async function AdminTenantPage({
</div>
</div>
{/* Sprachwahl je Mandant (steuert die Import-Sprache des Vorlagenpakets) */}
{/* Standardsprache je Mandant (Benachrichtigungen/Dokumente) */}
<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>
<p className="mb-1 font-heading text-sm font-semibold">{t("localeTitle")}</p>
<p className="mb-3 text-[12px] text-muted-foreground">{t("localeHint", { lang: s?.locale === "en" ? t("localeEn") : t("localeDe") })}</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>
<Button type="submit" variant={(s?.locale ?? "de") === "de" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={(s?.locale ?? "de") === "de"}>{t("localeDe")}</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>
<Button type="submit" variant={s?.locale === "en" ? "secondary" : "outline"} size="sm" className="w-full justify-center" disabled={s?.locale === "en"}>{t("localeEn")}</Button>
</form>
</div>
</div>
@@ -407,16 +329,9 @@ export default async function AdminTenantPage({
<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>
<p className="text-[11px] text-muted-foreground">{m.href ?? m.key}</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>
@@ -484,77 +399,6 @@ export default async function AdminTenantPage({
<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>
);
}
+6 -115
View File
@@ -16,49 +16,22 @@ import {
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([
const [tenants, mailStatus] = 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">
@@ -82,7 +55,7 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<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" />
<Input id="name" name="name" required className="mt-1" placeholder="Musterbau GmbH" />
</div>
<div>
<Label htmlFor="short">Kurzname</Label>
@@ -93,12 +66,12 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<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" />
<Label htmlFor="sector">Gewerk / Branche</Label>
<Input id="sector" name="sector" className="mt-1" placeholder="z. B. Sanitär, Heizung, Klima" />
</div>
<div>
<Label htmlFor="adminEmail">Admin-E-Mail *</Label>
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@muster.example" />
<Input id="adminEmail" name="adminEmail" type="email" required className="mt-1" placeholder="admin@betrieb.example" />
</div>
<div>
<Label htmlFor="adminName">Admin-Name</Label>
@@ -108,37 +81,13 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
<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).
Beim Anlegen werden automatisch ausgerollt: Standard-Rollen (Mandantenadministrator, Backoffice, Teamleiter, Monteur), erster Admin-User, alle Module und Mandanten-Einstellungen (idempotent, protokolliert).
</p>
</div>
)}
@@ -174,64 +123,6 @@ export default async function AdminPage({ searchParams }: { searchParams: Promis
</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>
);
+2 -3
View File
@@ -5,7 +5,7 @@ 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 { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { BRAND } from "@/lib/brand";
/**
@@ -30,11 +30,10 @@ export default async function PlatformLayout({ children }: Readonly<{ children:
<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} />
<CraftviaLogo variant="signet" 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}
@@ -1,73 +0,0 @@
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>
);
}

Some files were not shown because too many files have changed in this diff Show More