Iteration Teil D: Lieferantenmanagement (VDA-ISA 2027 Kap. 6 + NIS2)

Datenmodell (Prisma, RLS): Supplier + SupplierAsset, SupplierAssessment,
Contract, Nda, SupplierEvidence, ServiceResponsibility, Subcontractor,
ManagementDecision, SupplierControl (globaler Katalog) +
SupplierControlMaturity.

- VDA-ISA-2027-Kap.-6-Mini-Katalog (Controls 6.1.1–6.1.3) mit Zielbild,
  Muss/Soll, Anforderungen für hoch/sehr hoch, Simplified Group
  Assessment, Ziel-Reifegrad 3 und Cross-Referenzen (ISO/NIST/BSI); im
  Seed befüllt
- Lieferantenverzeichnis mit KPIs (gesamt, NIS2-relevant, ablaufend,
  Reviews fällig), Kritikalität, NIS2-Flag, Review-Fristen
- Detail-Popup: Stammdaten (Sektor/Leistung/Datenkategorien/CIA),
  betroffene Assets, VDA-ISA-Reifegrade je Control (Ziel 3, farbige
  Balken), Nachweise (Angemessenheit + Ablauf), Assessments, Verträge
  (AV/DPA, Flow-down, Fristen), NDAs (Fristen), Shared-Responsibility-
  Matrix, Subunternehmer, Managemententscheidungen
- Bearbeiten-Popup: Stammdaten (ein Speichern), Reifegrad je Control,
  Add/Delete für alle Kind-Entitäten, Löschen im ⋯-Menü
- Regel 6.1.1: fehlt geprüfter Audit-/TISAX-Nachweis → Warnung, dass
  eine dokumentierte risikobasierte Managemententscheidung nötig ist
- Server-Actions mit Zod/RBAC/Audit-Log; Seed mit Demo-Lieferant
  (TISAX-Label, AV/DPA, NDA, Assessment, RACI, Reifegrade)
- Menüpunkt „Lieferanten" aktiv; Lieferant ↔ Asset verknüpft (Graph)

Verifiziert: Register/Detail/Bearbeiten dunkel & vollständig, Reifegrad-
Save (6.1.2→4 mit Audit), Managemententscheidungs-Logik.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-03 10:23:02 +02:00
co-authored by Claude Opus 4.8
parent 18bd82e54a
commit 32ab91efbf
12 changed files with 2110 additions and 1 deletions
+146
View File
@@ -0,0 +1,146 @@
import Link from "next/link";
import { getFormatter, 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 { CriticalityPill, KpiCard, PageHead, Pill } from "@/components/mockup-ui";
import {
SupplierCreateModal,
SupplierDetailModal,
SupplierEditModal,
} from "@/components/supplier-modals";
import { supplierRef, SUPPLIER_STATUS_TONE, isExpiring, isReviewDue } from "@/lib/supplier";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const SUPPLIER_INCLUDE = {
assessments: true,
contracts: true,
ndas: true,
evidence: true,
responsibilities: true,
subcontractors: true,
decisions: true,
controlMaturity: true,
assetLinks: { include: { asset: { select: { id: true, name: true } } } },
} as const;
export default async function SuppliersPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string; edit?: string; new?: string }>;
}) {
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" },
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 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 })
: null;
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="/suppliers?new=1" />}>
<Plus className="size-4" /> {t("newSupplier")}
</Button>
)
}
/>
<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>
</TableRow>
</TableHeader>
<TableBody>
{suppliers.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
</TableRow>
)}
{suppliers.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{supplierRef(s.refNo)}</TableCell>
<TableCell>
<Link href={`/suppliers?detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
</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>
<TableCell><Pill tone={SUPPLIER_STATUS_TONE[s.status]}>{tStatus(s.status)}</Pill></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalSupplier && params.edit && canWrite ? (
<SupplierEditModal supplier={modalSupplier} controls={controls} />
) : modalSupplier ? (
<SupplierDetailModal supplier={modalSupplier} controls={controls} canWrite={canWrite} />
) : params.new && canWrite ? (
<SupplierCreateModal />
) : null}
</main>
);
}