- 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>
266 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|