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:
Martin
2026-07-02 14:40:42 +02:00
co-authored by Claude Fable 5
parent 0befcc6a2c
commit 9997597663
10 changed files with 615 additions and 179 deletions
+224 -38
View File
@@ -1,12 +1,19 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Plus } from "lucide-react";
import { Plus, X, Pencil } 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 { LevelBadge } from "@/components/level-badge";
import { ModuleTabs } from "@/components/module-tabs";
import {
CiaBadge,
CriticalityPill,
PageHead,
Pill,
SectTitle,
Tag,
} from "@/components/mockup-ui";
import {
Table,
TableBody,
@@ -16,58 +23,104 @@ import {
TableRow,
} from "@/components/ui/table";
export default async function ProcessesPage() {
export default async function ProcessesPage({
searchParams,
}: {
searchParams: Promise<{ detail?: string }>;
}) {
const session = await requireSession();
requirePermission(session, "bia:read");
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tType = await getTranslations("assetType");
const tCrit = await getTranslations("criticality");
const tc = await getTranslations("common");
const { detail } = await searchParams;
const db = dbForTenant(session.user.tenantId);
const processes = await db.process.findMany({
include: {
owner: { select: { name: true } },
bia: { select: { criticality: true, rtoHours: true, mtdHours: true } },
_count: { select: { processAssets: true } },
bia: { select: { criticality: true, rtoHours: true, rpoHours: true, mtdHours: true } },
},
orderBy: { name: "asc" },
take: 200,
});
// Read-only-Detail fürs Popup (?detail=<id>)
const detailProcess = detail
? await db.process.findUnique({
where: { id: detail },
include: {
owner: { select: { name: true } },
bia: true,
processAssets: {
include: {
asset: {
select: {
id: true,
name: true,
type: true,
confidentiality: true,
integrity: true,
availability: true,
owner: { select: { name: true } },
},
},
},
},
},
})
: null;
const canWrite = hasPermission(session, "bia:write");
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
const primary = detailProcess?.processAssets.filter((pa) => pa.role === "PRIMARY") ?? [];
const secondary = detailProcess?.processAssets.filter((pa) => pa.role === "SECONDARY") ?? [];
return (
<main className="flex-1 p-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold">{tAssets("title")}</h1>
{canWrite && (
<Button nativeButton={false} render={<Link href="/processes/new" />}>
<Plus className="size-4" /> {t("newProcess")}
</Button>
)}
</div>
<PageHead
crumb={tAssets("crumb")}
title={t("biaTitle")}
sub={t("biaSub")}
actions={
<>
<Button variant="outline" disabled title={tc("comingSoon")}>
{t("biaReport")}
</Button>
{canWrite && (
<Button nativeButton={false} render={<Link href="/processes/new" />}>
<Plus className="size-4" /> {t("newProcess")}
</Button>
)}
</>
}
/>
<div className="mt-4">
<ModuleTabs
active="/processes"
tabs={[
{ href: "/assets", label: tAssets("tabAssets") },
{ href: "/processes", label: tAssets("tabProcesses") },
]}
/>
</div>
<ModuleTabs
active="/processes"
tabs={[
{ href: "/assets", label: tAssets("tabAssets") },
{ href: "/processes", label: tAssets("tabProcesses") },
]}
/>
<div className="mt-4 rounded-lg border bg-card">
<Table>
<div className="shadow-card mt-4 rounded-xl border bg-card">
<div className="p-4 pb-0">
<SectTitle title={t("critTable")} />
</div>
<Table className="mt-2">
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("process")}</TableHead>
<TableHead>{t("owner")}</TableHead>
<TableHead>{t("criticality")}</TableHead>
<TableHead>RTO</TableHead>
<TableHead>RPO</TableHead>
<TableHead>MTD</TableHead>
<TableHead>{t("assets")}</TableHead>
<TableHead>{t("criticality")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -81,16 +134,19 @@ export default async function ProcessesPage() {
{processes.map((process) => (
<TableRow key={process.id}>
<TableCell>
<Link href={`/processes/${process.id}`} className="font-medium hover:underline">
<Link href={`/processes?detail=${process.id}`} className="font-bold hover:underline">
{process.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{process.owner?.name ?? tc("none")}
</TableCell>
<TableCell>{hours(process.bia?.rtoHours)}</TableCell>
<TableCell>{hours(process.bia?.rpoHours)}</TableCell>
<TableCell>{hours(process.bia?.mtdHours)}</TableCell>
<TableCell>
{process.bia ? (
<LevelBadge
<CriticalityPill
level={process.bia.criticality}
label={tCrit(String(process.bia.criticality))}
/>
@@ -98,20 +154,150 @@ export default async function ProcessesPage() {
<span className="text-muted-foreground">{t("noBia")}</span>
)}
</TableCell>
<TableCell className="text-muted-foreground">
{process.bia?.rtoHours != null ? `${process.bia.rtoHours} h` : tc("none")}
</TableCell>
<TableCell className="text-muted-foreground">
{process.bia?.mtdHours != null ? `${process.bia.mtdHours} h` : tc("none")}
</TableCell>
<TableCell className="text-muted-foreground">
{process._count.processAssets}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{detailProcess && (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]">
<div className="shadow-card w-full max-w-3xl rounded-2xl border bg-card">
<div className="flex items-start justify-between gap-3 border-b p-5">
<SectTitle
title={t("detailHeading", { name: detailProcess.name })}
sub={t("detailSub")}
/>
<div className="flex items-center gap-3">
{detailProcess.bia && (
<CriticalityPill
level={detailProcess.bia.criticality}
label={t("critLabel", {
label: tCrit(String(detailProcess.bia.criticality)),
})}
/>
)}
<Link
href="/processes"
title={t("close")}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4.5" />
</Link>
</div>
</div>
<div className="grid gap-5 p-5 md:grid-cols-2">
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="violet">{t("primaryPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("primaryNote")}</span>
</div>
{primary.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
{primary.map((pa) => (
<div
key={pa.id}
className="mb-2 rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4"
>
<div className="flex items-center justify-between gap-2">
<b>{pa.asset.name}</b>
<CiaBadge
c={pa.asset.confidentiality}
i={pa.asset.integrity}
a={pa.asset.availability}
/>
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("owner")}: {pa.asset.owner?.name ?? tc("none")} · {tAssets("type")}:{" "}
{tType(pa.asset.type)} · {t("inherits")}
</div>
</div>
))}
</div>
<div>
<div className="mb-2.5 flex items-center gap-2.5">
<Pill tone="info">{t("secondaryPill")}</Pill>
<span className="text-[12.5px] text-muted-foreground">{t("secondaryNote")}</span>
</div>
{secondary.length === 0 && (
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
)}
<table className="w-full text-sm">
<tbody>
{secondary.map((pa) => (
<tr key={pa.id} className="border-b last:border-0">
<td className="py-2.5 pr-2 font-bold">{pa.asset.name}</td>
<td className="py-2.5 pr-2">
<Tag>{tType(pa.asset.type)}</Tag>
</td>
<td className="py-2.5 text-right">
<CiaBadge
c={pa.asset.confidentiality}
i={pa.asset.integrity}
a={pa.asset.availability}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="mx-5 rounded-xl border border-[#e6e2f3] bg-[#f2f0f9] p-4 text-[12.5px] text-[#5a4e86]">
<div className="grid gap-2 sm:grid-cols-4">
<div>
<b>RTO</b>
<div className="mt-0.5">{hours(detailProcess.bia?.rtoHours)}</div>
</div>
<div>
<b>RPO</b>
<div className="mt-0.5">{hours(detailProcess.bia?.rpoHours)}</div>
</div>
<div>
<b>MTD</b>
<div className="mt-0.5">{hours(detailProcess.bia?.mtdHours)}</div>
</div>
<div>
<b>{t("impactShort")}</b>
<div className="mt-1">
{detailProcess.bia ? (
<CiaBadge
c={detailProcess.bia.impactC}
i={detailProcess.bia.impactI}
a={detailProcess.bia.impactA}
/>
) : (
t("noBia")
)}
</div>
</div>
</div>
{detailProcess.bia?.notes && (
<p className="mt-3 border-t border-[#e6e2f3] pt-2.5">{detailProcess.bia.notes}</p>
)}
</div>
<div className="flex justify-end gap-2 p-5">
{canWrite && (
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/processes/${detailProcess.id}`} />}
>
<Pencil className="size-4" /> {tc("edit")}
</Button>
)}
<Button nativeButton={false} render={<Link href="/processes" />}>
{t("close")}
</Button>
</div>
</div>
</div>
)}
</main>
);
}