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:
co-authored by
Claude Opus 4.8
parent
32ab91efbf
commit
dc884062b2
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user