- Risiko-Modul (SPEC §4.2): Risk/RiskAsset-Schema mit RLS, laufende Nummer je Mandant (R-001), Brutto-/Rest-Bewertung (5×5), Behandlung, Status, Owner- und Prozess-Bezug, n:m betroffene Assets - /risks im Mockup-Layout: 5×5-Heatmap (Farb-Bänder + R-Chips, Legende, Achsen) und Risikoregister mit Score-Pillen, Behandlungs-Tags, Status - Risiko-Popups nach App-Muster: Read-only-Detail (Bewertungs-Band Brutto vs. Rest, betroffene Assets, Maßnahmen-Platzhalter für Iteration 4), Bearbeiten im Popup (Bewertung, Assets verknüpfen, Löschen), Anlegen; Server-Actions mit Zod, RBAC und Audit-Log - Menü aufgetrennt: eigene Punkte "Asset-Inventar", "Business Impact Analyse" und "Risikoanalyse" (Modul-Tabs entfernt) - Prozesskategorie (Kernprozess/Managementprozess/Unterstützender Prozess): Schema-Feld, Formulare, Listen-Spalte, Popup-Tag - Asset-Detail-Popup optisch ans Prozess-Popup angeglichen (Stammdaten- Karte mit violettem Rand, Abhängigkeiten-Tabelle, Risiken-Band) - "Zugeordnete Risiken" jetzt echt: im Asset-Popup (verknüpfte Risiken) und im Prozess-Popup (direkt + über Assets aggregiert); Dashboard-KPI "Offene Risiken" mit echten Zahlen; Seed mit 4 Beispiel-Risiken Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
252 lines
8.7 KiB
TypeScript
252 lines
8.7 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,
|
|
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", "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>{t("protection")} (C/I/A)</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>
|
|
);
|
|
}
|