diff --git a/messages/de.json b/messages/de.json index 52c081e..7a4b727 100644 --- a/messages/de.json +++ b/messages/de.json @@ -87,7 +87,24 @@ "editTitle": "Asset bearbeiten", "createTitle": "Asset anlegen", "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": { "INFORMATION": "Information", @@ -132,7 +149,24 @@ "impact": "Schadenshöhe je Schutzziel (1–4)", "notes": "Anmerkungen / Schadensszenarien", "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": { "PRIMARY": "Primär", diff --git a/messages/en.json b/messages/en.json index 744a4ad..14cf612 100644 --- a/messages/en.json +++ b/messages/en.json @@ -87,7 +87,24 @@ "editTitle": "Edit asset", "createTitle": "Create asset", "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": { "INFORMATION": "Information", @@ -132,7 +149,24 @@ "impact": "Damage level per protection goal (1–4)", "notes": "Notes / damage scenarios", "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": { "PRIMARY": "Primary", diff --git a/src/app/(app)/assets/[id]/page.tsx b/src/app/(app)/assets/[id]/page.tsx index dd9cc42..8db6f03 100644 --- a/src/app/(app)/assets/[id]/page.tsx +++ b/src/app/(app)/assets/[id]/page.tsx @@ -11,9 +11,8 @@ import { removeAssetRelation, } from "@/server/actions/assets"; import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; 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({ params, @@ -62,8 +61,10 @@ export default async function AssetDetailPage({

{asset.name}

- {tType(asset.type)} - {tStatus(asset.status)} + {tType(asset.type)} + + {tStatus(asset.status)} + {canWrite && (
@@ -93,20 +94,14 @@ export default async function AssetDetailPage({
{t("location")}
{asset.location ?? tc("none")}
{t("protection")}
-
- - - +
+
{t("tags")}
{asset.tags.length === 0 ? tc("none") - : asset.tags.map((tag) => ( - - {tag} - - ))} + : asset.tags.map((tag) => {tag})}
@@ -196,9 +191,7 @@ export default async function AssetDetailPage({ {pa.process.name} - - {tRole(pa.role)} - + {tRole(pa.role)} ))} diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx index a52532f..ee77420 100644 --- a/src/app/(app)/assets/page.tsx +++ b/src/app/(app)/assets/page.tsx @@ -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 (
-
-

{t("title")}

- {canWrite && ( - - )} + + + + {canWrite && ( + + )} + + } + /> + + + +
+ + + 0 ? t("kpiNoOwnerTrend") : undefined} + trendColor="warn" + /> +
-
- -
- -
- - - - -
- -
+
+
+ ({ + href: typeHref(v), + label: tType(v), + active: activeType === v, + })), + ]} + /> +
+ {activeType && } + + + +
- {t("name")} + Asset {t("type")} - {t("protection")} (C/I/A) {t("owner")} + {t("protection")} (C/I/A) + {t("dependencies")} {t("status")} {assets.length === 0 && ( - + {t("empty")} @@ -131,25 +167,26 @@ export default async function AssetsPage({ {assets.map((asset) => ( - + {asset.name} - {tType(asset.type)} + {tType(asset.type)} - - - - - + + + + - {asset.owner?.name ?? tc("none")} + {asset.relationsFrom.length + ? asset.relationsFrom.map((r) => r.relatedAsset.name).join(", ") + : tc("none")} - - {tStatus(asset.status)} + + {tStatus(asset.status)} ))} diff --git a/src/app/(app)/processes/[id]/page.tsx b/src/app/(app)/processes/[id]/page.tsx index 49e47e9..746e34d 100644 --- a/src/app/(app)/processes/[id]/page.tsx +++ b/src/app/(app)/processes/[id]/page.tsx @@ -13,10 +13,10 @@ import { } from "@/server/actions/processes"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { CiaBadge, CriticalityPill } from "@/components/mockup-ui"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; -import { LevelBadge } from "@/components/level-badge"; const LEVELS = [1, 2, 3, 4] as const; @@ -84,11 +84,7 @@ export default async function ProcessDetailPage({ {pa.asset.name} - - - - - + {canWrite && (

{process.name}

- {bia && ( - - )} + {bia && } {canWrite && (
@@ -212,10 +206,8 @@ export default async function ProcessDetailPage({
MTD
{bia.mtdHours != null ? `${bia.mtdHours} h` : tc("none")}
{t("impact")}
-
- - - +
+
)} diff --git a/src/app/(app)/processes/page.tsx b/src/app/(app)/processes/page.tsx index d046c0e..d9e0dd3 100644 --- a/src/app/(app)/processes/page.tsx +++ b/src/app/(app)/processes/page.tsx @@ -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=) + 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 (
-
-

{tAssets("title")}

- {canWrite && ( - - )} -
+ + + {canWrite && ( + + )} + + } + /> -
- -
+ -
-
+
+
+ +
+
- {t("name")} + {t("process")} {t("owner")} - {t("criticality")} RTO + RPO MTD - {t("assets")} + {t("criticality")} @@ -81,16 +134,19 @@ export default async function ProcessesPage() { {processes.map((process) => ( - + {process.name} {process.owner?.name ?? tc("none")} + {hours(process.bia?.rtoHours)} + {hours(process.bia?.rpoHours)} + {hours(process.bia?.mtdHours)} {process.bia ? ( - @@ -98,20 +154,150 @@ export default async function ProcessesPage() { {t("noBia")} )} - - {process.bia?.rtoHours != null ? `${process.bia.rtoHours} h` : tc("none")} - - - {process.bia?.mtdHours != null ? `${process.bia.mtdHours} h` : tc("none")} - - - {process._count.processAssets} - ))}
+ + {detailProcess && ( +
+
+
+ +
+ {detailProcess.bia && ( + + )} + + + +
+
+ +
+
+
+ {t("primaryPill")} + {t("primaryNote")} +
+ {primary.length === 0 && ( +

{t("noAssets")}

+ )} + {primary.map((pa) => ( +
+
+ {pa.asset.name} + +
+
+ {t("owner")}: {pa.asset.owner?.name ?? tc("none")} · {tAssets("type")}:{" "} + {tType(pa.asset.type)} · {t("inherits")} +
+
+ ))} +
+ +
+
+ {t("secondaryPill")} + {t("secondaryNote")} +
+ {secondary.length === 0 && ( +

{t("noAssets")}

+ )} + + + {secondary.map((pa) => ( + + + + + + ))} + +
{pa.asset.name} + {tType(pa.asset.type)} + + +
+
+
+ +
+
+
+ RTO +
{hours(detailProcess.bia?.rtoHours)}
+
+
+ RPO +
{hours(detailProcess.bia?.rpoHours)}
+
+
+ MTD +
{hours(detailProcess.bia?.mtdHours)}
+
+
+ {t("impactShort")} +
+ {detailProcess.bia ? ( + + ) : ( + t("noBia") + )} +
+
+
+ {detailProcess.bia?.notes && ( +

{detailProcess.bia.notes}

+ )} +
+ +
+ {canWrite && ( + + )} + +
+
+
+ )}
); } diff --git a/src/components/filter-tabs.tsx b/src/components/filter-tabs.tsx new file mode 100644 index 0000000..1661c75 --- /dev/null +++ b/src/components/filter-tabs.tsx @@ -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 ( +
+ {tabs.map((tab) => ( + + {tab.label} + + ))} +
+ ); +} diff --git a/src/components/level-badge.tsx b/src/components/level-badge.tsx deleted file mode 100644 index 4ae7662..0000000 --- a/src/components/level-badge.tsx +++ /dev/null @@ -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 ( - - {prefix ? `${prefix}${level}` : label ?? level} - - ); -} diff --git a/src/components/mockup-ui.tsx b/src/components/mockup-ui.tsx new file mode 100644 index 0000000..fd41e74 --- /dev/null +++ b/src/components/mockup-ui.tsx @@ -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 ( +
+
+
{crumb}
+

{title}

+ {sub &&
{sub}
} +
+ {actions &&
{actions}
} +
+ ); +} + +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 ( +
+
{label}
+
{value}
+ {trend &&
{trend}
} +
+ ); +} + +/** Schutzbedarf/Schadenshöhe als drei Quadrate (C/I/A) wie im Mockup. */ +const CIA_COLORS: Record = { + 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 ( + + {[c, i, a].map((v, idx) => ( + + {v} + + ))} + + ); +} + +export function Tag({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function OwnerChip({ name, noOwnerLabel }: { name?: string | null; noOwnerLabel: string }) { + if (!name) return {noOwnerLabel}; + const initials = name + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase(); + return ( + + + {initials} + + {name} + + ); +} + +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 ( + + {children} + + ); +} + +/** 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 {label}; +} + +export function SectTitle({ title, sub }: { title: string; sub?: string }) { + return ( +
+
{title}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index abeaced..c1e708c 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -57,7 +57,7 @@ function TableRow({ className, ...props }: React.ComponentProps<"tr">) { ) {