Files
certvia/src/app/(app)/suppliers/page.tsx
T
msolarczekandClaude Opus 4.8 d994c94ebb IT-Service-Detail mit RACI-Matrix (VDA-ISA 6.1.3)
- IT-Services als Assets (type IT_SERVICE) mit ITServiceProfile, Provider-Verknüpfung
- Detail-Cockpit: Stammdaten, verknüpfte Assets/Prozesse, Risiken
- RACI-/Shared-Responsibility-Matrix über den ISA-Control-Katalog
  (Control hinzufügen via datalist, Verantwortung per Klick durchschalten,
   PROVIDER/US/SHARED, Nachweis-Referenz, Löschen)
- Lieferanten/IT-Services als Pillen-Tabs (FilterTabs) auf /suppliers
- Server-Actions services.ts (create/update/delete/addRaci/cycleRaci/deleteRaci)
  mit Zod, RBAC (supplier:write) und Audit-Log
- ISA_CONTROLS-Vorschlagsliste (isa-controls.ts)
- Demo-IT-Service "Managed ERP-Hosting" mit 5 RACI-Zeilen im Seed
- de/en Übersetzungen (services, raciParty)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 14:06:04 +02:00

266 lines
10 KiB
TypeScript

import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Plus } 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 { PageHead, Pill, CriticalityPill, CiaBadge } from "@/components/mockup-ui";
import { FilterTabs } from "@/components/filter-tabs";
import {
SupplierCreateModal,
SupplierDetailModal,
SupplierEditModal,
type SupplierAssetDetail,
} from "@/components/supplier-modals";
import {
ServiceCreateModal,
ServiceDetailModal,
ServiceEditModal,
type ServiceAssetDetail,
} from "@/components/service-modals";
import {
supplierRef,
serviceRef,
protectionLevel,
PROTECTION_LABEL,
buildReqContext,
conformity,
computedMaturity,
CONFORMITY_TONE,
CONFORMITY_LABEL,
TARGET_MATURITY,
} from "@/lib/supplier";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const SUPPLIER_INCLUDE = {
supplierProfile: true,
contracts: true,
ndas: true,
evidence: true,
assessments: true,
subcontractors: true,
decisions: 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;
const SERVICE_INCLUDE = {
serviceProfile: { include: { provider: { select: { id: true, name: true } } } },
raci: true,
riskAssets: { include: { risk: { select: { id: true, refNo: true, title: true } } } },
relationsFrom: { include: { relatedAsset: { select: { id: true, name: true } } } },
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } },
} as const;
export default async function SuppliersPage({
searchParams,
}: {
searchParams: Promise<{ tab?: string; detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "supplier:read");
const t = await getTranslations("suppliers");
const ts = await getTranslations("services");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const params = await searchParams;
const isServices = params.tab === "services";
const db = dbForTenant(session.user.tenantId);
const canWrite = hasPermission(session, "supplier:write");
const tabs = [
{ href: "/suppliers", label: ts("tabSuppliers"), active: !isServices },
{ href: "/suppliers?tab=services", label: ts("tabServices"), active: isServices },
];
const head = (
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("sub")}
actions={
canWrite && (
<Button nativeButton={false} render={<Link href={isServices ? "/suppliers?tab=services&new=1" : "/suppliers?new=1"} />}>
<Plus className="size-4" /> {isServices ? ts("newService") : t("newSupplier")}
</Button>
)
}
/>
);
/* ── IT-Services ── */
if (isServices) {
const services = (await db.asset.findMany({
where: { type: "IT_SERVICE", serviceProfile: { isNot: null } },
include: SERVICE_INCLUDE,
orderBy: { serviceProfile: { refNo: "asc" } },
take: 300,
})) as ServiceAssetDetail[];
const providers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
select: { id: true, name: true },
orderBy: { name: "asc" },
});
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalService = modalId
? ((await db.asset.findUnique({ where: { id: modalId }, include: SERVICE_INCLUDE })) as ServiceAssetDetail | null)
: null;
return (
<main className="flex-1 p-6">
{head}
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>{ts("ref")}</TableHead>
<TableHead>{ts("name")}</TableHead>
<TableHead>{ts("provider")}</TableHead>
<TableHead>{ts("protection")}</TableHead>
<TableHead>{ts("criticality")}</TableHead>
<TableHead>{ts("raciCoverage")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{ts("empty")}</TableCell>
</TableRow>
)}
{services.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-muted-foreground">{serviceRef(s.serviceProfile!.refNo)}</TableCell>
<TableCell>
<Link href={`/suppliers?tab=services&detail=${s.id}`} className="font-bold hover:underline">{s.name}</Link>
</TableCell>
<TableCell className="text-muted-foreground">{s.serviceProfile?.provider?.name ?? (s.serviceProfile?.internal ? ts("internal") : tc("none"))}</TableCell>
<TableCell><CiaBadge c={s.confidentiality} i={s.integrity} a={s.availability} /></TableCell>
<TableCell><CriticalityPill level={s.serviceProfile!.criticality} label={tCrit(String(s.serviceProfile!.criticality))} /></TableCell>
<TableCell className="text-muted-foreground">{s.raci.length}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalService && params.edit && canWrite ? (
<ServiceEditModal service={modalService} providers={providers} />
) : modalService ? (
<ServiceDetailModal service={modalService} canWrite={canWrite} />
) : params.new && canWrite ? (
<ServiceCreateModal providers={providers} />
) : null}
</main>
);
}
/* ── Lieferanten ── */
const suppliers = await db.asset.findMany({
where: { type: "SUPPLIER", supplierProfile: { isNot: null } },
include: SUPPLIER_INCLUDE,
orderBy: { supplierProfile: { refNo: "asc" } },
take: 300,
});
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 modalId = params.edit && canWrite ? params.edit : params.detail;
const modalSupplier = modalId
? ((await db.asset.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
: null;
return (
<main className="flex-1 p-6">
{head}
<div className="mt-3"><FilterTabs tabs={tabs} /></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("linkedAssets")}</TableHead>
<TableHead>{t("derivedLevel")}</TableHead>
<TableHead>{t("maturity")}</TableHead>
<TableHead>{t("conformity")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">{t("empty")}</TableCell>
</TableRow>
)}
{rows.map(({ s, level, conf, maturity, linked }) => (
<TableRow key={s.id}>
<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="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>
<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>
</Table>
</div>
{modalSupplier && params.edit && canWrite ? (
<SupplierEditModal supplier={modalSupplier} />
) : modalSupplier ? (
<SupplierDetailModal supplier={modalSupplier} canWrite={canWrite} />
) : params.new && canWrite ? (
<SupplierCreateModal />
) : null}
</main>
);
}