Lieferantenmodul neu: Asset-basiert + Anforderungs-Engine (Phase 1)

Referenz: docs/ISMS-Lieferantenmanagement-GEFIM.html

Architektur: Lieferanten (und IT-Services) sind jetzt Assets.
- AssetType += IT_SERVICE; SupplierProfile/ITServiceProfile als 1:1-
  Erweiterung eines Asset; alle Kind-Entitäten (Contract, Nda,
  SupplierEvidence, SupplierAssessment, ManagementDecision, Subcontractor,
  ServiceControlResponsibility, MaturityAssessment) hängen am Asset
  (keine parallele Datenhaltung). Migration + RLS; Demo neu geseedet.
- Lieferanten erscheinen im Asset-Inventar (Filter „Lieferant"/„IT-Service"),
  tragen C/I/A, nutzbar in Graph/BIA/Risiko.

Anforderungs-Engine (Herzstück, src/lib/supplier.ts):
- Schutzbedarf aus max C/I/A der verknüpften Assets → Stufe normal/hoch/
  sehr hoch; schaltet Anforderungen gestuft scharf (must/should/high/veryhigh)
- Flache Anforderungsliste (nach Themen gruppiert) mit Status-Ableitung aus
  den hinterlegten Objekten, Referenz + Herkunfts-Control (6.1.1–6.1.3)
- Gate-Logik: sehr hoch ohne Audit/Label → Kompensation (Management-
  entscheidung UND verknüpftes Risiko); „Risiko anlegen" erzeugt echten Risk
- Konformität (Konform/Teilweise/Lücken/Handlung nötig) + berechneter
  Reifegrad; ISB-Freigabe (Abweichung nur mit Begründung → Audit-Log)
- Cockpit-Detail mit Schutzbedarf-Banner + Live-Simulation der Stufe

Verifiziert: Register mit abgeleiteter Stufe/Reifegrad/Konformität,
Cockpit-Simulation (Tiers werden inaktiv), Gate bei sehr hoch ohne Audit,
„Risiko anlegen" erzeugt R-005 im Risikomodul.

Offen (Phase 2): Fragebogen-Builder, Self-Service-Portal, IT-Service-
Detail mit RACI-Matrix, Klick im Asset-Inventar öffnet Cockpit,
Scheduler-Automatisierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-03 11:57:17 +02:00
co-authored by Claude Opus 4.8
parent 32ab91efbf
commit dc884062b2
17 changed files with 1605 additions and 972 deletions
+72 -52
View File
@@ -1,17 +1,28 @@
import Link from "next/link";
import { getFormatter, getTranslations } from "next-intl/server";
import { getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { requireSession } from "@/server/auth";
import { dbForTenant, prisma } from "@/server/db";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { CriticalityPill, KpiCard, PageHead, Pill } from "@/components/mockup-ui";
import { PageHead, Pill } from "@/components/mockup-ui";
import {
SupplierCreateModal,
SupplierDetailModal,
SupplierEditModal,
type SupplierAssetDetail,
} from "@/components/supplier-modals";
import { supplierRef, SUPPLIER_STATUS_TONE, isExpiring, isReviewDue } from "@/lib/supplier";
import {
supplierRef,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
conformity,
computedMaturity,
CONFORMITY_TONE,
CONFORMITY_LABEL,
TARGET_MATURITY,
} from "@/lib/supplier";
import {
Table,
TableBody,
@@ -21,16 +32,18 @@ import {
TableRow,
} from "@/components/ui/table";
const SUPPLIER_INCLUDE = {
assessments: true,
const DETAIL_INCLUDE = {
supplierProfile: true,
contracts: true,
ndas: true,
evidence: true,
responsibilities: true,
assessments: true,
subcontractors: true,
decisions: true,
controlMaturity: true,
assetLinks: { include: { asset: { select: { id: true, name: true } } } },
maturity: true,
riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true, score: true } } } },
relationsFrom: { include: { relatedAsset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
relationsTo: { include: { asset: { select: { name: true, confidentiality: true, integrity: true, availability: true } } } },
} as const;
export default async function SuppliersPage({
@@ -41,37 +54,49 @@ export default async function SuppliersPage({
const session = await requireSession();
requirePermission(session, "supplier:read");
const t = await getTranslations("suppliers");
const tStatus = await getTranslations("supplierStatus");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const fmt = await getFormatter();
const params = await searchParams;
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "supplier:write");
const suppliers = await db.supplier.findMany({
include: {
contracts: { select: { validTo: true } },
evidence: { select: { validTo: true } },
_count: { select: { assessments: true, contracts: true } },
},
orderBy: { refNo: "asc" },
const suppliers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
include: DETAIL_INCLUDE,
orderBy: { supplierProfile: { refNo: "asc" } },
take: 300,
});
const total = suppliers.length;
const nis2 = suppliers.filter((s) => s.nis2Relevant).length;
const expiring = suppliers.filter(
(s) =>
s.contracts.some((c) => isExpiring(c.validTo)) || s.evidence.some((e) => isExpiring(e.validTo))
).length;
const reviewsDue = suppliers.filter((s) => isReviewDue(s.nextReview)).length;
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 controls = await prisma.supplierControl.findMany({ orderBy: { ref: "asc" } });
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalSupplier = modalId
? await db.supplier.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })
? ((await db.asset.findUnique({ where: { id: modalId }, include: DETAIL_INCLUDE })) as SupplierAssetDetail | null)
: null;
return (
@@ -89,45 +114,40 @@ export default async function SuppliersPage({
}
/>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiTotal")} value={total} />
<KpiCard label={t("kpiNis2")} value={nis2} trend="NIS2" trendColor="warn" />
<KpiCard label={t("kpiExpiring")} value={expiring} trendColor="warn" trend={expiring > 0 ? t("kpiExpiring") : undefined} />
<KpiCard label={t("kpiReviews")} value={reviewsDue} trendColor="risk" trend={reviewsDue > 0 ? t("kpiReviews") : undefined} />
</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("sector")}</TableHead>
<TableHead>{t("criticality")}</TableHead>
<TableHead>NIS2</TableHead>
<TableHead>{t("nextReview")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead>{t("linkedAssets")}</TableHead>
<TableHead>{t("derivedLevel")}</TableHead>
<TableHead>{t("maturity")}</TableHead>
<TableHead>{t("conformity")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{suppliers.length === 0 && (
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
</TableRow>
)}
{suppliers.map((s) => (
{rows.map(({ s, level, conf, maturity, linked }) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{supplierRef(s.refNo)}</TableCell>
<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="text-muted-foreground">{s.sector ?? tc("none")}</TableCell>
<TableCell><CriticalityPill level={s.criticality} label={tCrit(String(s.criticality))} /></TableCell>
<TableCell>{s.nis2Relevant ? <Pill tone="info">NIS2</Pill> : <span className="text-muted-foreground">{tc("none")}</span>}</TableCell>
<TableCell className={isReviewDue(s.nextReview) ? "text-[var(--warn)]" : "text-muted-foreground"}>
{s.nextReview ? fmt.dateTime(s.nextReview, { dateStyle: "medium" }) : tc("none")}
<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><Pill tone={SUPPLIER_STATUS_TONE[s.status]}>{tStatus(s.status)}</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>
@@ -135,9 +155,9 @@ export default async function SuppliersPage({
</div>
{modalSupplier && params.edit && canWrite ? (
<SupplierEditModal supplier={modalSupplier} controls={controls} />
<SupplierEditModal supplier={modalSupplier} />
) : modalSupplier ? (
<SupplierDetailModal supplier={modalSupplier} controls={controls} canWrite={canWrite} />
<SupplierDetailModal supplier={modalSupplier} canWrite={canWrite} />
) : params.new && canWrite ? (
<SupplierCreateModal />
) : null}