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:
co-authored by
Claude Opus 4.8
parent
18bd82e54a
commit
32ab91efbf
@@ -43,7 +43,7 @@ export default async function AppLayout({
|
||||
{ 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: false },
|
||||
{ href: "/suppliers", label: t("suppliers"), icon: Truck, enabled: true },
|
||||
{ href: "/review", label: t("review"), icon: LineChart, enabled: false },
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user