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>
This commit is contained in:
@@ -5,15 +5,23 @@ 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 } from "@/components/mockup-ui";
|
||||
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,
|
||||
@@ -32,7 +40,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const DETAIL_INCLUDE = {
|
||||
const SUPPLIER_INCLUDE = {
|
||||
supplierProfile: true,
|
||||
contracts: true,
|
||||
ndas: true,
|
||||
@@ -46,23 +54,125 @@ const DETAIL_INCLUDE = {
|
||||
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<{ detail?: string; edit?: string; new?: string }>;
|
||||
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: DETAIL_INCLUDE,
|
||||
include: SUPPLIER_INCLUDE,
|
||||
orderBy: { supplierProfile: { refNo: "asc" } },
|
||||
take: 300,
|
||||
});
|
||||
@@ -96,24 +206,13 @@ export default async function SuppliersPage({
|
||||
|
||||
const modalId = params.edit && canWrite ? params.edit : params.detail;
|
||||
const modalSupplier = modalId
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: DETAIL_INCLUDE })) as SupplierAssetDetail | null)
|
||||
? ((await db.asset.findUnique({ where: { id: modalId }, include: SUPPLIER_INCLUDE })) as SupplierAssetDetail | null)
|
||||
: 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{head}
|
||||
<div className="mt-3"><FilterTabs tabs={tabs} /></div>
|
||||
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
|
||||
Reference in New Issue
Block a user