Files
certvia/src/app/(app)/assets/page.tsx
T
MartinandClaude Opus 4.8 dc884062b2 Lieferantenmodul neu: Asset-basiert + Anforderungs-Engine (Phase 1)
Referenz: docs/ISMS-Lieferantenmanagement-GEFIM.html

Architektur: Lieferanten (und IT-Services) sind jetzt Assets.
- AssetType += IT_SERVICE; SupplierProfile/ITServiceProfile als 1:1-
  Erweiterung eines Asset; alle Kind-Entitäten (Contract, Nda,
  SupplierEvidence, SupplierAssessment, ManagementDecision, Subcontractor,
  ServiceControlResponsibility, MaturityAssessment) hängen am Asset
  (keine parallele Datenhaltung). Migration + RLS; Demo neu geseedet.
- Lieferanten erscheinen im Asset-Inventar (Filter „Lieferant"/„IT-Service"),
  tragen C/I/A, nutzbar in Graph/BIA/Risiko.

Anforderungs-Engine (Herzstück, src/lib/supplier.ts):
- Schutzbedarf aus max C/I/A der verknüpften Assets → Stufe normal/hoch/
  sehr hoch; schaltet Anforderungen gestuft scharf (must/should/high/veryhigh)
- Flache Anforderungsliste (nach Themen gruppiert) mit Status-Ableitung aus
  den hinterlegten Objekten, Referenz + Herkunfts-Control (6.1.1–6.1.3)
- Gate-Logik: sehr hoch ohne Audit/Label → Kompensation (Management-
  entscheidung UND verknüpftes Risiko); „Risiko anlegen" erzeugt echten Risk
- Konformität (Konform/Teilweise/Lücken/Handlung nötig) + berechneter
  Reifegrad; ISB-Freigabe (Abweichung nur mit Begründung → Audit-Log)
- Cockpit-Detail mit Schutzbedarf-Banner + Live-Simulation der Stufe

Verifiziert: Register mit abgeleiteter Stufe/Reifegrad/Konformität,
Cockpit-Simulation (Tiers werden inaktiv), Gate bei sehr hoch ohne Audit,
„Risiko anlegen" erzeugt R-005 im Risikomodul.

Offen (Phase 2): Fragebogen-Builder, Self-Service-Portal, IT-Service-
Detail mit RACI-Matrix, Klick im Asset-Inventar öffnet Cockpit,
Scheduler-Automatisierung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 11:57:17 +02:00

258 lines
8.8 KiB
TypeScript

import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Plus, Search } from "lucide-react";
import type { AssetType } from "@prisma/client";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { hasPermission, requirePermission } from "@/server/rbac";
import { Button } from "@/components/ui/button";
import { FilterTabs } from "@/components/filter-tabs";
import {
CiaBadge,
CiaLegend,
KpiCard,
OwnerChip,
PageHead,
Pill,
Tag,
} from "@/components/mockup-ui";
import {
AssetCreateModal,
AssetDetailModal,
AssetEditModal,
} from "@/components/asset-modals";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
export default async function AssetsPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; type?: string; detail?: string; edit?: string; new?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "asset:read");
const t = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tStatus = await getTranslations("assetStatus");
const tc = await getTranslations("common");
const params = await searchParams;
const { q, type } = params;
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
const db = dbForTenant(session.user.tenantId);
const [assets, allAssets] = await Promise.all([
db.asset.findMany({
where: {
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
...(activeType ? { type: activeType } : {}),
},
include: {
owner: { select: { name: true } },
relationsFrom: {
include: { relatedAsset: { select: { name: true } } },
take: 4,
},
},
orderBy: { name: "asc" },
take: 200,
}),
db.asset.findMany({
select: { type: true, ownerId: true, confidentiality: true, integrity: true, availability: true },
}),
]);
const total = allAssets.length;
const typeCount = new Set(allAssets.map((a) => a.type)).size;
const highProtection = allAssets.filter(
(a) => Math.max(a.confidentiality, a.integrity, a.availability) >= 3
).length;
const withoutOwner = allAssets.filter((a) => !a.ownerId).length;
const suppliers = allAssets.filter((a) => a.type === "SUPPLIER").length;
const canWrite = hasPermission(session, "asset:write");
// Popup-Zustand aus searchParams: ?detail= (read-only), ?edit=, ?new=1
const modalId = params.edit && canWrite ? params.edit : params.detail;
const modalAsset = modalId
? await db.asset.findUnique({
where: { id: modalId },
include: {
owner: { select: { name: true } },
relationsFrom: {
include: {
relatedAsset: {
select: {
id: true,
name: true,
type: true,
confidentiality: true,
integrity: true,
availability: true,
},
},
},
},
relationsTo: { include: { asset: { select: { id: true, name: true } } } },
processAssets: { include: { process: { select: { id: true, name: true } } } },
riskAssets: {
include: { risk: { select: { id: true, refNo: true, title: true, score: true } } },
orderBy: { risk: { score: "desc" } },
},
},
})
: null;
const users =
canWrite && (params.edit || params.new)
? await db.user.findMany({
where: { status: "ACTIVE" },
select: { id: true, name: true },
orderBy: { name: "asc" },
})
: [];
const otherAssets =
canWrite && params.edit && modalAsset
? await db.asset.findMany({
where: { id: { not: modalAsset.id } },
select: { id: true, name: true },
orderBy: { name: "asc" },
})
: [];
const typeHref = (v: string | null) =>
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
return (
<main className="flex-1 p-6">
<PageHead
crumb={t("crumb")}
title={t("invTitle")}
sub={t("invSub", { count: total })}
actions={
<>
<Button variant="outline" disabled title={tc("comingSoon")}>
{t("excelImport")}
</Button>
<Button variant="outline" disabled title={tc("comingSoon")}>
{t("export")}
</Button>
{canWrite && (
<Button nativeButton={false} render={<Link href="/assets?new=1" />}>
<Plus className="size-4" /> {t("newAsset")}
</Button>
)}
</>
}
/>
<div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard label={t("kpiTotal")} value={total} trend={t("kpiTotalTrend", { count: typeCount })} />
<KpiCard label={t("kpiHigh")} value={highProtection} trend={t("kpiHighTrend")} trendColor="risk" />
<KpiCard
label={t("kpiNoOwner")}
value={withoutOwner}
trend={withoutOwner > 0 ? t("kpiNoOwnerTrend") : undefined}
trendColor="warn"
/>
<KpiCard label={t("kpiSuppliers")} value={suppliers} trend={t("kpiSuppliersTrend")} />
</div>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<div className="flex flex-wrap items-center justify-between gap-3 p-4">
<FilterTabs
tabs={[
{ href: typeHref(null), label: t("all"), active: !activeType },
...ASSET_TYPES.map((v) => ({
href: typeHref(v),
label: tType(v),
active: activeType === v,
})),
]}
/>
<form method="GET" className="flex max-w-65 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-muted-foreground">
{activeType && <input type="hidden" name="type" value={activeType} />}
<Search className="size-[15px] shrink-0" />
<input
name="q"
defaultValue={q}
placeholder={t("filter")}
className="w-full border-0 bg-transparent text-[13px] text-foreground outline-none"
/>
</form>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Asset</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("owner")}</TableHead>
<TableHead>
<span className="flex flex-col gap-1">
{t("protection")}
<CiaLegend />
</span>
</TableHead>
<TableHead>{t("dependencies")}</TableHead>
<TableHead>{t("status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{assets.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
{t("empty")}
</TableCell>
</TableRow>
)}
{assets.map((asset) => (
<TableRow key={asset.id}>
<TableCell>
<Link href={`/assets?detail=${asset.id}`} className="font-bold hover:underline">
{asset.name}
</Link>
</TableCell>
<TableCell>
<Tag>{tType(asset.type)}</Tag>
</TableCell>
<TableCell>
<OwnerChip name={asset.owner?.name} noOwnerLabel={t("noOwner")} />
</TableCell>
<TableCell>
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
</TableCell>
<TableCell className="text-muted-foreground">
{asset.relationsFrom.length
? asset.relationsFrom.map((r) => r.relatedAsset.name).join(", ")
: tc("none")}
</TableCell>
<TableCell>
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{modalAsset && params.edit && canWrite ? (
<AssetEditModal asset={modalAsset} users={users} otherAssets={otherAssets} />
) : modalAsset ? (
<AssetDetailModal asset={modalAsset} canWrite={canWrite} />
) : params.new && canWrite ? (
<AssetCreateModal users={users} />
) : null}
</main>
);
}