Verwaltete Register (GAP-Report §5.2) sind nicht mehr nur benannte Dokumente, sondern editierbare Register mit Pflichtspalten und Cross-Links. - Schema: ManagedRegister (code, title, columns JSON, reviewCycle, responsible, supplierLink/assetLink) + RegisterRow (values JSON, supplierRef, assetRef). Migration + RLS; in TENANT_MODELS. - import-managed.ts: 7 generische Register (REG-PROJECTS, -SENS-ROLES, -AUDIT-PLAN, -EXT-SERVICES, -SW-WHITELIST, -CRIT-SERVICES, -NET) mit definierten Pflichtspalten + Cross-Link-Flags; legt Register-Dokument (Bibliothek/Link) UND ManagedRegister- Definition idempotent an (RegisterRow bleibt bei Re-Import erhalten). - actions/register.ts: addRegisterRow/updateRegisterRow/deleteRegisterRow (Modul policies, policy:write; Werte gegen die Pflichtspalten, optional Lieferant/Asset). - components/generic-register.tsx: editierbare Zeilen-Tabelle + Cross-Link-Selects (Lieferant = Asset SUPPLIER/IT_SERVICE, Asset = alle); in /policies/[code] eingebunden. Browser-verifiziert: REG-EXT-SERVICES rendert 7 Spalten + Lieferant/Asset-Selects; Eintrag anlegen persistiert alle Werte. tsc + lint + build + Guard-Check grün. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
121 lines
5.4 KiB
TypeScript
121 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|