Assets & BIA optisch 1:1 ans Mockup angeglichen
- Neue Mockup-Bausteine (mockup-ui.tsx): PageHead mit Crumb/Untertitel,
KPI-Kapseln, C/I/A-Quadrate in Mockup-Farben, Tag-Chips, Owner-Chips
mit Mini-Avatar ("kein Owner" als rote Pille), Status-/Kritikalitäts-
Pillen, Pillen-Filter-Tabs
- Asset-Inventar wie im Mockup: Seitenkopf mit Zähler-Untertitel und
Ghost-Buttons (Excel-Import/Export, deaktiviert bis zur Funktion),
4 KPI-Kapseln (gesamt, hoher Schutzbedarf, ohne Owner, Lieferanten),
Typ-Filter als Pillen-Tabs + Filtersuche in der Tabellenkarte,
Abhängigkeiten-Spalte
- BIA-Seite wie im Mockup: "Kritikalität der Prozesse" mit RTO/RPO/MTD
und Kritikalitäts-Pillen; Prozess-Detail als schließbares, schreib-
geschütztes Popup (?detail=id, serverseitig gerendert): primäres Asset
mit violettem Kartenrand, Sekundär-Tabelle, BIA-Kennzahlen-Band,
Bearbeiten-Button führt zur Editier-Seite
- Tabellenköpfe uppercase/muted, Zeilen-Hover wie im Mockup; Asset-
Detail und Prozess-Editier-Seite auf die neuen Bausteine umgestellt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+124
-87
@@ -1,15 +1,21 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { AssetStatus, AssetType } from "@prisma/client";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { LevelBadge } from "@/components/level-badge";
|
||||
import { ModuleTabs } from "@/components/module-tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FilterTabs } from "@/components/filter-tabs";
|
||||
import {
|
||||
CiaBadge,
|
||||
KpiCard,
|
||||
OwnerChip,
|
||||
PageHead,
|
||||
Pill,
|
||||
Tag,
|
||||
} from "@/components/mockup-ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,12 +26,13 @@ import {
|
||||
} from "@/components/ui/table";
|
||||
|
||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
||||
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] 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; status?: string }>;
|
||||
searchParams: Promise<{ q?: string; type?: string }>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
requirePermission(session, "asset:read");
|
||||
@@ -34,96 +41,125 @@ export default async function AssetsPage({
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const { q, type, status } = await searchParams;
|
||||
const { q, type } = await searchParams;
|
||||
const activeType = ASSET_TYPES.includes(type as AssetType) ? (type as AssetType) : null;
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const assets = await db.asset.findMany({
|
||||
where: {
|
||||
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
||||
...(type && ASSET_TYPES.includes(type as AssetType) ? { type: type as AssetType } : {}),
|
||||
...(status && ASSET_STATUS.includes(status as AssetStatus)
|
||||
? { status: status as AssetStatus }
|
||||
: {}),
|
||||
},
|
||||
include: { owner: { select: { name: true } } },
|
||||
orderBy: { name: "asc" },
|
||||
take: 200,
|
||||
});
|
||||
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");
|
||||
|
||||
const typeHref = (v: string | null) =>
|
||||
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
||||
{canWrite && (
|
||||
<Button nativeButton={false} render={<Link href="/assets/new" />}>
|
||||
<Plus className="size-4" /> {t("newAsset")}
|
||||
</Button>
|
||||
)}
|
||||
<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" />}>
|
||||
<Plus className="size-4" /> {t("newAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ModuleTabs
|
||||
active="/assets"
|
||||
tabs={[
|
||||
{ href: "/assets", label: t("tabAssets") },
|
||||
{ href: "/processes", label: t("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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="mt-4">
|
||||
<ModuleTabs
|
||||
active="/assets"
|
||||
tabs={[
|
||||
{ href: "/assets", label: t("tabAssets") },
|
||||
{ href: "/processes", label: t("tabProcesses") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form method="GET" className="mt-4 flex flex-wrap gap-2">
|
||||
<Input
|
||||
name="q"
|
||||
defaultValue={q}
|
||||
placeholder={tc("search")}
|
||||
className="w-56"
|
||||
/>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue={type ?? ""}
|
||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="">{t("allTypes")}</option>
|
||||
{ASSET_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tType(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={status ?? ""}
|
||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
<option value="">{t("allStatus")}</option>
|
||||
{ASSET_STATUS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tStatus(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary">
|
||||
{tc("search").replace("…", "")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 rounded-lg border bg-card">
|
||||
<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>{t("name")}</TableHead>
|
||||
<TableHead>Asset</TableHead>
|
||||
<TableHead>{t("type")}</TableHead>
|
||||
<TableHead>{t("protection")} (C/I/A)</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={5} className="py-8 text-center text-muted-foreground">
|
||||
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -131,25 +167,26 @@ export default async function AssetsPage({
|
||||
{assets.map((asset) => (
|
||||
<TableRow key={asset.id}>
|
||||
<TableCell>
|
||||
<Link href={`/assets/${asset.id}`} className="font-medium hover:underline">
|
||||
<Link href={`/assets/${asset.id}`} className="font-bold hover:underline">
|
||||
{asset.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
||||
<Tag>{tType(asset.type)}</Tag>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="flex gap-1">
|
||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
||||
<LevelBadge level={asset.integrity} prefix="I" />
|
||||
<LevelBadge level={asset.availability} prefix="A" />
|
||||
</span>
|
||||
<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.owner?.name ?? tc("none")}
|
||||
{asset.relationsFrom.length
|
||||
? asset.relationsFrom.map((r) => r.relatedAsset.name).join(", ")
|
||||
: tc("none")}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{tStatus(asset.status)}
|
||||
<TableCell>
|
||||
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user