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:
+36
-2
@@ -87,7 +87,24 @@
|
|||||||
"editTitle": "Asset bearbeiten",
|
"editTitle": "Asset bearbeiten",
|
||||||
"createTitle": "Asset anlegen",
|
"createTitle": "Asset anlegen",
|
||||||
"deleted": "Asset gelöscht",
|
"deleted": "Asset gelöscht",
|
||||||
"inheritedNote": "Vererbter Schutzbedarf aus Prozessen (Max-Prinzip) wird bei der BIA berücksichtigt."
|
"inheritedNote": "Vererbter Schutzbedarf aus Prozessen (Max-Prinzip) wird bei der BIA berücksichtigt.",
|
||||||
|
"crumb": "Fachdaten",
|
||||||
|
"invTitle": "Asset-Inventar",
|
||||||
|
"invSub": "{count} Werte · Schutzbedarf nach C/I/A · verknüpft mit BIA und Risiko",
|
||||||
|
"excelImport": "Excel-Import",
|
||||||
|
"export": "Export",
|
||||||
|
"kpiTotal": "Assets gesamt",
|
||||||
|
"kpiTotalTrend": "{count} Typen",
|
||||||
|
"kpiHigh": "Hoher Schutzbedarf",
|
||||||
|
"kpiHighTrend": "C/I/A ≥ 3",
|
||||||
|
"kpiNoOwner": "Ohne Owner",
|
||||||
|
"kpiNoOwnerTrend": "Zuweisung nötig",
|
||||||
|
"kpiSuppliers": "Lieferanten",
|
||||||
|
"kpiSuppliersTrend": "extern",
|
||||||
|
"all": "Alle",
|
||||||
|
"filter": "Filtern …",
|
||||||
|
"noOwner": "kein Owner",
|
||||||
|
"dependencies": "Abhängigkeiten"
|
||||||
},
|
},
|
||||||
"assetType": {
|
"assetType": {
|
||||||
"INFORMATION": "Information",
|
"INFORMATION": "Information",
|
||||||
@@ -132,7 +149,24 @@
|
|||||||
"impact": "Schadenshöhe je Schutzziel (1–4)",
|
"impact": "Schadenshöhe je Schutzziel (1–4)",
|
||||||
"notes": "Anmerkungen / Schadensszenarien",
|
"notes": "Anmerkungen / Schadensszenarien",
|
||||||
"biaSaved": "BIA gespeichert",
|
"biaSaved": "BIA gespeichert",
|
||||||
"noBia": "Noch keine BIA erfasst."
|
"noBia": "Noch keine BIA erfasst.",
|
||||||
|
"biaTitle": "Business Impact Analyse",
|
||||||
|
"biaSub": "Kritikalität der Geschäftsprozesse · RTO / RPO / MTD",
|
||||||
|
"biaReport": "BIA-Report (PDF)",
|
||||||
|
"critTable": "Kritikalität der Prozesse",
|
||||||
|
"process": "Geschäftsprozess",
|
||||||
|
"detailHeading": "Prozess-Detail · {name}",
|
||||||
|
"detailSub": "Zugeordnete Assets nach Rolle",
|
||||||
|
"critLabel": "Kritikalität: {label}",
|
||||||
|
"primaryPill": "★ Primäres Asset",
|
||||||
|
"primaryNote": "Ergebnis, das im Prozess erzeugt wird",
|
||||||
|
"secondaryPill": "◆ Sekundäre Assets",
|
||||||
|
"secondaryNote": "Werden zur Bearbeitung benötigt",
|
||||||
|
"inherits": "vererbt Schutzbedarf an sekundäre Assets",
|
||||||
|
"showDependencies": "Abhängigkeiten anzeigen →",
|
||||||
|
"impactShort": "Schadenshöhe (C/I/A)",
|
||||||
|
"close": "Schließen",
|
||||||
|
"noAssets": "Noch keine Assets zugeordnet."
|
||||||
},
|
},
|
||||||
"processRole": {
|
"processRole": {
|
||||||
"PRIMARY": "Primär",
|
"PRIMARY": "Primär",
|
||||||
|
|||||||
+36
-2
@@ -87,7 +87,24 @@
|
|||||||
"editTitle": "Edit asset",
|
"editTitle": "Edit asset",
|
||||||
"createTitle": "Create asset",
|
"createTitle": "Create asset",
|
||||||
"deleted": "Asset deleted",
|
"deleted": "Asset deleted",
|
||||||
"inheritedNote": "Inherited protection needs from processes (max principle) are considered in the BIA."
|
"inheritedNote": "Inherited protection needs from processes (max principle) are considered in the BIA.",
|
||||||
|
"crumb": "Core data",
|
||||||
|
"invTitle": "Asset inventory",
|
||||||
|
"invSub": "{count} assets · protection needs by C/I/A · linked to BIA and risk",
|
||||||
|
"excelImport": "Excel import",
|
||||||
|
"export": "Export",
|
||||||
|
"kpiTotal": "Total assets",
|
||||||
|
"kpiTotalTrend": "{count} types",
|
||||||
|
"kpiHigh": "High protection needs",
|
||||||
|
"kpiHighTrend": "C/I/A ≥ 3",
|
||||||
|
"kpiNoOwner": "Without owner",
|
||||||
|
"kpiNoOwnerTrend": "assignment needed",
|
||||||
|
"kpiSuppliers": "Suppliers",
|
||||||
|
"kpiSuppliersTrend": "external",
|
||||||
|
"all": "All",
|
||||||
|
"filter": "Filter …",
|
||||||
|
"noOwner": "no owner",
|
||||||
|
"dependencies": "Dependencies"
|
||||||
},
|
},
|
||||||
"assetType": {
|
"assetType": {
|
||||||
"INFORMATION": "Information",
|
"INFORMATION": "Information",
|
||||||
@@ -132,7 +149,24 @@
|
|||||||
"impact": "Damage level per protection goal (1–4)",
|
"impact": "Damage level per protection goal (1–4)",
|
||||||
"notes": "Notes / damage scenarios",
|
"notes": "Notes / damage scenarios",
|
||||||
"biaSaved": "BIA saved",
|
"biaSaved": "BIA saved",
|
||||||
"noBia": "No BIA recorded yet."
|
"noBia": "No BIA recorded yet.",
|
||||||
|
"biaTitle": "Business impact analysis",
|
||||||
|
"biaSub": "Criticality of business processes · RTO / RPO / MTD",
|
||||||
|
"biaReport": "BIA report (PDF)",
|
||||||
|
"critTable": "Process criticality",
|
||||||
|
"process": "Business process",
|
||||||
|
"detailHeading": "Process detail · {name}",
|
||||||
|
"detailSub": "Assigned assets by role",
|
||||||
|
"critLabel": "Criticality: {label}",
|
||||||
|
"primaryPill": "★ Primary asset",
|
||||||
|
"primaryNote": "Result produced by the process",
|
||||||
|
"secondaryPill": "◆ Secondary assets",
|
||||||
|
"secondaryNote": "Required for processing",
|
||||||
|
"inherits": "inherits protection needs to secondary assets",
|
||||||
|
"showDependencies": "Show dependencies →",
|
||||||
|
"impactShort": "Damage level (C/I/A)",
|
||||||
|
"close": "Close",
|
||||||
|
"noAssets": "No assets assigned yet."
|
||||||
},
|
},
|
||||||
"processRole": {
|
"processRole": {
|
||||||
"PRIMARY": "Primary",
|
"PRIMARY": "Primary",
|
||||||
|
|||||||
@@ -11,9 +11,8 @@ import {
|
|||||||
removeAssetRelation,
|
removeAssetRelation,
|
||||||
} from "@/server/actions/assets";
|
} from "@/server/actions/assets";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { LevelBadge } from "@/components/level-badge";
|
import { CiaBadge, Pill, Tag } from "@/components/mockup-ui";
|
||||||
|
|
||||||
export default async function AssetDetailPage({
|
export default async function AssetDetailPage({
|
||||||
params,
|
params,
|
||||||
@@ -62,8 +61,10 @@ export default async function AssetDetailPage({
|
|||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold">{asset.name}</h1>
|
<h1 className="text-xl font-semibold">{asset.name}</h1>
|
||||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
<Tag>{tType(asset.type)}</Tag>
|
||||||
<Badge variant="outline">{tStatus(asset.status)}</Badge>
|
<Pill tone={asset.status === "ACTIVE" ? "ok" : asset.status === "PLANNED" ? "info" : "mut"}>
|
||||||
|
{tStatus(asset.status)}
|
||||||
|
</Pill>
|
||||||
</div>
|
</div>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -93,20 +94,14 @@ export default async function AssetDetailPage({
|
|||||||
<dt className="text-muted-foreground">{t("location")}</dt>
|
<dt className="text-muted-foreground">{t("location")}</dt>
|
||||||
<dd>{asset.location ?? tc("none")}</dd>
|
<dd>{asset.location ?? tc("none")}</dd>
|
||||||
<dt className="text-muted-foreground">{t("protection")}</dt>
|
<dt className="text-muted-foreground">{t("protection")}</dt>
|
||||||
<dd className="flex gap-1">
|
<dd>
|
||||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
|
||||||
<LevelBadge level={asset.integrity} prefix="I" />
|
|
||||||
<LevelBadge level={asset.availability} prefix="A" />
|
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-muted-foreground">{t("tags")}</dt>
|
<dt className="text-muted-foreground">{t("tags")}</dt>
|
||||||
<dd className="flex flex-wrap gap-1">
|
<dd className="flex flex-wrap gap-1">
|
||||||
{asset.tags.length === 0
|
{asset.tags.length === 0
|
||||||
? tc("none")
|
? tc("none")
|
||||||
: asset.tags.map((tag) => (
|
: asset.tags.map((tag) => <Tag key={tag}>{tag}</Tag>)}
|
||||||
<Badge key={tag} variant="secondary">
|
|
||||||
{tag}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -196,9 +191,7 @@ export default async function AssetDetailPage({
|
|||||||
<Link href={`/processes/${pa.process.id}`} className="hover:underline">
|
<Link href={`/processes/${pa.process.id}`} className="hover:underline">
|
||||||
{pa.process.name}
|
{pa.process.name}
|
||||||
</Link>
|
</Link>
|
||||||
<Badge variant={pa.role === "PRIMARY" ? "default" : "secondary"}>
|
<Pill tone={pa.role === "PRIMARY" ? "violet" : "info"}>{tRole(pa.role)}</Pill>
|
||||||
{tRole(pa.role)}
|
|
||||||
</Badge>
|
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
+124
-87
@@ -1,15 +1,21 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, Search } from "lucide-react";
|
||||||
import type { AssetStatus, AssetType } from "@prisma/client";
|
import type { AssetType } from "@prisma/client";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||||
import { Button } from "@/components/ui/button";
|
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 { 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 {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -20,12 +26,13 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
|
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({
|
export default async function AssetsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; type?: string; status?: string }>;
|
searchParams: Promise<{ q?: string; type?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "asset:read");
|
requirePermission(session, "asset:read");
|
||||||
@@ -34,96 +41,125 @@ export default async function AssetsPage({
|
|||||||
const tStatus = await getTranslations("assetStatus");
|
const tStatus = await getTranslations("assetStatus");
|
||||||
const tc = await getTranslations("common");
|
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 db = dbForTenant(session.user.tenantId);
|
||||||
|
|
||||||
const assets = await db.asset.findMany({
|
const [assets, allAssets] = await Promise.all([
|
||||||
where: {
|
db.asset.findMany({
|
||||||
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
where: {
|
||||||
...(type && ASSET_TYPES.includes(type as AssetType) ? { type: type as AssetType } : {}),
|
...(q ? { name: { contains: q, mode: "insensitive" } } : {}),
|
||||||
...(status && ASSET_STATUS.includes(status as AssetStatus)
|
...(activeType ? { type: activeType } : {}),
|
||||||
? { status: status as AssetStatus }
|
},
|
||||||
: {}),
|
include: {
|
||||||
},
|
owner: { select: { name: true } },
|
||||||
include: { owner: { select: { name: true } } },
|
relationsFrom: {
|
||||||
orderBy: { name: "asc" },
|
include: { relatedAsset: { select: { name: true } } },
|
||||||
take: 200,
|
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 canWrite = hasPermission(session, "asset:write");
|
||||||
|
|
||||||
|
const typeHref = (v: string | null) =>
|
||||||
|
`/assets${v ? `?type=${v}` : ""}${q ? `${v ? "&" : "?"}q=${encodeURIComponent(q)}` : ""}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHead
|
||||||
<h1 className="text-xl font-semibold">{t("title")}</h1>
|
crumb={t("crumb")}
|
||||||
{canWrite && (
|
title={t("invTitle")}
|
||||||
<Button nativeButton={false} render={<Link href="/assets/new" />}>
|
sub={t("invSub", { count: total })}
|
||||||
<Plus className="size-4" /> {t("newAsset")}
|
actions={
|
||||||
</Button>
|
<>
|
||||||
)}
|
<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>
|
||||||
|
|
||||||
<div className="mt-4">
|
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||||
<ModuleTabs
|
<div className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||||
active="/assets"
|
<FilterTabs
|
||||||
tabs={[
|
tabs={[
|
||||||
{ href: "/assets", label: t("tabAssets") },
|
{ href: typeHref(null), label: t("all"), active: !activeType },
|
||||||
{ href: "/processes", label: t("tabProcesses") },
|
...ASSET_TYPES.map((v) => ({
|
||||||
]}
|
href: typeHref(v),
|
||||||
/>
|
label: tType(v),
|
||||||
</div>
|
active: activeType === v,
|
||||||
|
})),
|
||||||
<form method="GET" className="mt-4 flex flex-wrap gap-2">
|
]}
|
||||||
<Input
|
/>
|
||||||
name="q"
|
<form method="GET" className="flex max-w-65 items-center gap-2 rounded-lg border bg-muted px-3 py-2 text-muted-foreground">
|
||||||
defaultValue={q}
|
{activeType && <input type="hidden" name="type" value={activeType} />}
|
||||||
placeholder={tc("search")}
|
<Search className="size-[15px] shrink-0" />
|
||||||
className="w-56"
|
<input
|
||||||
/>
|
name="q"
|
||||||
<select
|
defaultValue={q}
|
||||||
name="type"
|
placeholder={t("filter")}
|
||||||
defaultValue={type ?? ""}
|
className="w-full border-0 bg-transparent text-[13px] text-foreground outline-none"
|
||||||
className="h-9 rounded-md border border-input bg-transparent px-3 text-sm"
|
/>
|
||||||
>
|
</form>
|
||||||
<option value="">{t("allTypes")}</option>
|
</div>
|
||||||
{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">
|
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("name")}</TableHead>
|
<TableHead>Asset</TableHead>
|
||||||
<TableHead>{t("type")}</TableHead>
|
<TableHead>{t("type")}</TableHead>
|
||||||
<TableHead>{t("protection")} (C/I/A)</TableHead>
|
|
||||||
<TableHead>{t("owner")}</TableHead>
|
<TableHead>{t("owner")}</TableHead>
|
||||||
|
<TableHead>{t("protection")} (C/I/A)</TableHead>
|
||||||
|
<TableHead>{t("dependencies")}</TableHead>
|
||||||
<TableHead>{t("status")}</TableHead>
|
<TableHead>{t("status")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{assets.length === 0 && (
|
{assets.length === 0 && (
|
||||||
<TableRow>
|
<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")}
|
{t("empty")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -131,25 +167,26 @@ export default async function AssetsPage({
|
|||||||
{assets.map((asset) => (
|
{assets.map((asset) => (
|
||||||
<TableRow key={asset.id}>
|
<TableRow key={asset.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/assets/${asset.id}`} className="font-medium hover:underline">
|
<Link href={`/assets/${asset.id}`} className="font-bold hover:underline">
|
||||||
{asset.name}
|
{asset.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{tType(asset.type)}</Badge>
|
<Tag>{tType(asset.type)}</Tag>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="flex gap-1">
|
<OwnerChip name={asset.owner?.name} noOwnerLabel={t("noOwner")} />
|
||||||
<LevelBadge level={asset.confidentiality} prefix="C" />
|
</TableCell>
|
||||||
<LevelBadge level={asset.integrity} prefix="I" />
|
<TableCell>
|
||||||
<LevelBadge level={asset.availability} prefix="A" />
|
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
|
||||||
</span>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<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>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell>
|
||||||
{tStatus(asset.status)}
|
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import {
|
|||||||
} from "@/server/actions/processes";
|
} from "@/server/actions/processes";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { CiaBadge, CriticalityPill } from "@/components/mockup-ui";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { LevelBadge } from "@/components/level-badge";
|
|
||||||
|
|
||||||
const LEVELS = [1, 2, 3, 4] as const;
|
const LEVELS = [1, 2, 3, 4] as const;
|
||||||
|
|
||||||
@@ -84,11 +84,7 @@ export default async function ProcessDetailPage({
|
|||||||
<Link href={`/assets/${pa.asset.id}`} className="hover:underline">
|
<Link href={`/assets/${pa.asset.id}`} className="hover:underline">
|
||||||
{pa.asset.name}
|
{pa.asset.name}
|
||||||
</Link>
|
</Link>
|
||||||
<span className="flex gap-1">
|
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||||
<LevelBadge level={pa.asset.confidentiality} prefix="C" />
|
|
||||||
<LevelBadge level={pa.asset.integrity} prefix="I" />
|
|
||||||
<LevelBadge level={pa.asset.availability} prefix="A" />
|
|
||||||
</span>
|
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<form action={unassignAsset.bind(null, process!.id, pa.id)}>
|
<form action={unassignAsset.bind(null, process!.id, pa.id)}>
|
||||||
<button
|
<button
|
||||||
@@ -114,9 +110,7 @@ export default async function ProcessDetailPage({
|
|||||||
<ArrowLeft className="size-4" />
|
<ArrowLeft className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<h1 className="text-xl font-semibold">{process.name}</h1>
|
<h1 className="text-xl font-semibold">{process.name}</h1>
|
||||||
{bia && (
|
{bia && <CriticalityPill level={bia.criticality} label={tCrit(String(bia.criticality))} />}
|
||||||
<LevelBadge level={bia.criticality} label={tCrit(String(bia.criticality))} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{canWrite && (
|
{canWrite && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -212,10 +206,8 @@ export default async function ProcessDetailPage({
|
|||||||
<dt className="text-muted-foreground">MTD</dt>
|
<dt className="text-muted-foreground">MTD</dt>
|
||||||
<dd>{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}</dd>
|
<dd>{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}</dd>
|
||||||
<dt className="text-muted-foreground">{t("impact")}</dt>
|
<dt className="text-muted-foreground">{t("impact")}</dt>
|
||||||
<dd className="flex gap-1">
|
<dd>
|
||||||
<LevelBadge level={bia.impactC} prefix="C" />
|
<CiaBadge c={bia.impactC} i={bia.impactI} a={bia.impactA} />
|
||||||
<LevelBadge level={bia.impactI} prefix="I" />
|
|
||||||
<LevelBadge level={bia.impactA} prefix="A" />
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus, X, Pencil } from "lucide-react";
|
||||||
import { requireSession } from "@/server/auth";
|
import { requireSession } from "@/server/auth";
|
||||||
import { dbForTenant } from "@/server/db";
|
import { dbForTenant } from "@/server/db";
|
||||||
import { hasPermission, requirePermission } from "@/server/rbac";
|
import { hasPermission, requirePermission } from "@/server/rbac";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { LevelBadge } from "@/components/level-badge";
|
|
||||||
import { ModuleTabs } from "@/components/module-tabs";
|
import { ModuleTabs } from "@/components/module-tabs";
|
||||||
|
import {
|
||||||
|
CiaBadge,
|
||||||
|
CriticalityPill,
|
||||||
|
PageHead,
|
||||||
|
Pill,
|
||||||
|
SectTitle,
|
||||||
|
Tag,
|
||||||
|
} from "@/components/mockup-ui";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -16,58 +23,104 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
|
|
||||||
export default async function ProcessesPage() {
|
export default async function ProcessesPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ detail?: string }>;
|
||||||
|
}) {
|
||||||
const session = await requireSession();
|
const session = await requireSession();
|
||||||
requirePermission(session, "bia:read");
|
requirePermission(session, "bia:read");
|
||||||
const t = await getTranslations("processes");
|
const t = await getTranslations("processes");
|
||||||
const tAssets = await getTranslations("assets");
|
const tAssets = await getTranslations("assets");
|
||||||
|
const tType = await getTranslations("assetType");
|
||||||
const tCrit = await getTranslations("criticality");
|
const tCrit = await getTranslations("criticality");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
|
const { detail } = await searchParams;
|
||||||
const db = dbForTenant(session.user.tenantId);
|
const db = dbForTenant(session.user.tenantId);
|
||||||
|
|
||||||
const processes = await db.process.findMany({
|
const processes = await db.process.findMany({
|
||||||
include: {
|
include: {
|
||||||
owner: { select: { name: true } },
|
owner: { select: { name: true } },
|
||||||
bia: { select: { criticality: true, rtoHours: true, mtdHours: true } },
|
bia: { select: { criticality: true, rtoHours: true, rpoHours: true, mtdHours: true } },
|
||||||
_count: { select: { processAssets: true } },
|
|
||||||
},
|
},
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
take: 200,
|
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 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 (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-6">
|
||||||
<div className="flex items-center justify-between">
|
<PageHead
|
||||||
<h1 className="text-xl font-semibold">{tAssets("title")}</h1>
|
crumb={tAssets("crumb")}
|
||||||
{canWrite && (
|
title={t("biaTitle")}
|
||||||
<Button nativeButton={false} render={<Link href="/processes/new" />}>
|
sub={t("biaSub")}
|
||||||
<Plus className="size-4" /> {t("newProcess")}
|
actions={
|
||||||
</Button>
|
<>
|
||||||
)}
|
<Button variant="outline" disabled title={tc("comingSoon")}>
|
||||||
</div>
|
{t("biaReport")}
|
||||||
|
</Button>
|
||||||
|
{canWrite && (
|
||||||
|
<Button nativeButton={false} render={<Link href="/processes/new" />}>
|
||||||
|
<Plus className="size-4" /> {t("newProcess")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4">
|
<ModuleTabs
|
||||||
<ModuleTabs
|
active="/processes"
|
||||||
active="/processes"
|
tabs={[
|
||||||
tabs={[
|
{ href: "/assets", label: tAssets("tabAssets") },
|
||||||
{ href: "/assets", label: tAssets("tabAssets") },
|
{ href: "/processes", label: tAssets("tabProcesses") },
|
||||||
{ href: "/processes", label: tAssets("tabProcesses") },
|
]}
|
||||||
]}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-lg border bg-card">
|
<div className="shadow-card mt-4 rounded-xl border bg-card">
|
||||||
<Table>
|
<div className="p-4 pb-0">
|
||||||
|
<SectTitle title={t("critTable")} />
|
||||||
|
</div>
|
||||||
|
<Table className="mt-2">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>{t("name")}</TableHead>
|
<TableHead>{t("process")}</TableHead>
|
||||||
<TableHead>{t("owner")}</TableHead>
|
<TableHead>{t("owner")}</TableHead>
|
||||||
<TableHead>{t("criticality")}</TableHead>
|
|
||||||
<TableHead>RTO</TableHead>
|
<TableHead>RTO</TableHead>
|
||||||
|
<TableHead>RPO</TableHead>
|
||||||
<TableHead>MTD</TableHead>
|
<TableHead>MTD</TableHead>
|
||||||
<TableHead>{t("assets")}</TableHead>
|
<TableHead>{t("criticality")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@@ -81,16 +134,19 @@ export default async function ProcessesPage() {
|
|||||||
{processes.map((process) => (
|
{processes.map((process) => (
|
||||||
<TableRow key={process.id}>
|
<TableRow key={process.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Link href={`/processes/${process.id}`} className="font-medium hover:underline">
|
<Link href={`/processes?detail=${process.id}`} className="font-bold hover:underline">
|
||||||
{process.name}
|
{process.name}
|
||||||
</Link>
|
</Link>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
{process.owner?.name ?? tc("none")}
|
{process.owner?.name ?? tc("none")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{hours(process.bia?.rtoHours)}</TableCell>
|
||||||
|
<TableCell>{hours(process.bia?.rpoHours)}</TableCell>
|
||||||
|
<TableCell>{hours(process.bia?.mtdHours)}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{process.bia ? (
|
{process.bia ? (
|
||||||
<LevelBadge
|
<CriticalityPill
|
||||||
level={process.bia.criticality}
|
level={process.bia.criticality}
|
||||||
label={tCrit(String(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>
|
<span className="text-muted-foreground">{t("noBia")}</span>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** Pillen-Tabs wie im Mockup (Filter per Link/searchParams, serverseitig). */
|
||||||
|
export function FilterTabs({
|
||||||
|
tabs,
|
||||||
|
}: {
|
||||||
|
tabs: { href: string; label: string; active: boolean }[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
href={tab.href}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border px-3.5 py-[7px] font-heading text-[12.5px] font-semibold transition-colors",
|
||||||
|
tab.active
|
||||||
|
? "border-transparent bg-[#e6e2f3] text-[#5d52a3]"
|
||||||
|
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { levelColor } from "@/lib/levels";
|
|
||||||
|
|
||||||
/** Stufen-Badge 1–4, optional mit Präfix (z. B. "C", "RTO"). */
|
|
||||||
export function LevelBadge({
|
|
||||||
level,
|
|
||||||
label,
|
|
||||||
prefix,
|
|
||||||
}: {
|
|
||||||
level: number;
|
|
||||||
label?: string;
|
|
||||||
prefix?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Badge variant="outline" className={`font-bold ${levelColor(level)}`}>
|
|
||||||
{prefix ? `${prefix}${level}` : label ?? level}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Basisbausteine im Look des Mockups (docs/ISMS-Prototyp-GEFIM.html):
|
||||||
|
* PageHead (Crumb/Titel/Sub + Aktionen), KPI-Kapsel, C/I/A-Quadrate,
|
||||||
|
* Tag-Chip, Owner-Chip, Status-Pille.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function PageHead({
|
||||||
|
crumb,
|
||||||
|
title,
|
||||||
|
sub,
|
||||||
|
actions,
|
||||||
|
}: {
|
||||||
|
crumb: string;
|
||||||
|
title: string;
|
||||||
|
sub?: string;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mb-4.5 flex flex-wrap items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{crumb}</div>
|
||||||
|
<h1 className="text-[22px]">{title}</h1>
|
||||||
|
{sub && <div className="mt-1 text-[13px] text-muted-foreground">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
{actions && <div className="flex gap-2">{actions}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KpiCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
trend,
|
||||||
|
trendColor = "muted",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
trend?: string;
|
||||||
|
trendColor?: "muted" | "risk" | "warn" | "ok";
|
||||||
|
}) {
|
||||||
|
const trendColors = {
|
||||||
|
muted: "text-muted-foreground",
|
||||||
|
risk: "text-[#d64c4c]",
|
||||||
|
warn: "text-[#e0982e]",
|
||||||
|
ok: "text-[#2e9e6b]",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<div className="text-[12.5px] font-semibold text-muted-foreground">{label}</div>
|
||||||
|
<div className="mt-1.5 mb-0.5 font-heading text-3xl font-bold leading-none">{value}</div>
|
||||||
|
{trend && <div className={cn("mt-1 text-xs font-semibold", trendColors[trendColor])}>{trend}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schutzbedarf/Schadenshöhe als drei Quadrate (C/I/A) wie im Mockup. */
|
||||||
|
const CIA_COLORS: Record<number, string> = {
|
||||||
|
1: "bg-[#9db2c9]",
|
||||||
|
2: "bg-[#5d8fbf]",
|
||||||
|
3: "bg-[#e0982e]",
|
||||||
|
4: "bg-[#d64c4c]",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CiaBadge({ c, i, a }: { c: number; i: number; a: number }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex gap-[3px]" title={`C${c} · I${i} · A${a}`}>
|
||||||
|
{[c, i, a].map((v, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={cn(
|
||||||
|
"grid size-5 place-items-center rounded-md text-[10px] font-bold text-white",
|
||||||
|
CIA_COLORS[v] ?? CIA_COLORS[1]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tag({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-block rounded-md bg-[#eef0f4] px-2 py-0.5 text-[11px] font-bold text-[#555]">
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OwnerChip({ name, noOwnerLabel }: { name?: string | null; noOwnerLabel: string }) {
|
||||||
|
if (!name) return <Pill tone="risk">{noOwnerLabel}</Pill>;
|
||||||
|
const initials = name
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((p) => p[0])
|
||||||
|
.slice(0, 2)
|
||||||
|
.join("")
|
||||||
|
.toUpperCase();
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-2">
|
||||||
|
<span className="grid size-6.5 place-items-center rounded-full bg-[#e6e2f3] font-heading text-[11px] font-bold text-[#5d52a3]">
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PILL_TONES = {
|
||||||
|
ok: "bg-[#e7f6ee] text-[#2e9e6b]",
|
||||||
|
warn: "bg-[#fbf1de] text-[#a9760f]",
|
||||||
|
risk: "bg-[#fbe7e7] text-[#d64c4c]",
|
||||||
|
info: "bg-[#e7f1fa] text-[#3a86c8]",
|
||||||
|
mut: "bg-[#eef0f4] text-muted-foreground",
|
||||||
|
violet: "bg-[#e6e2f3] text-[#5d52a3]",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function Pill({
|
||||||
|
tone,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tone: keyof typeof PILL_TONES;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[11.5px] font-bold whitespace-nowrap",
|
||||||
|
PILL_TONES[tone]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Kritikalität 1–4 als Pille in Mockup-Farben (info/warn/risk). */
|
||||||
|
export function CriticalityPill({ level, label }: { level: number; label: string }) {
|
||||||
|
const tone = level >= 4 ? "risk" : level === 3 ? "warn" : level === 2 ? "info" : "ok";
|
||||||
|
return <Pill tone={tone}>{label}</Pill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SectTitle({ title, sub }: { title: string; sub?: string }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="font-heading text-[15px] font-semibold">{title}</div>
|
||||||
|
{sub && <div className="mt-0.5 text-[12.5px] text-muted-foreground">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
|||||||
<tr
|
<tr
|
||||||
data-slot="table-row"
|
data-slot="table-row"
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
"border-b transition-colors hover:bg-[#faf9fd] has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
"h-10 px-3 text-left align-middle text-[11.5px] font-bold tracking-[.04em] uppercase whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
Reference in New Issue
Block a user