GUI-Bausteine: CiaGauge, SegmentedRating, Prozess-Dialog aufgeräumt

Referenz: docs/ISMS-GUI-Verbesserungen-GEFIM.html

- CiaGauge: Schutzbedarf/Schadenshöhe als 4er-Segmentbalken (Farblogik
  1–4) statt Zahlen-Quadrate — einheitlich in allen Tabellen/Karten;
  in Detailkarten mit VER/INT/VFB-Labels
- SegmentedRating: 1–4 anklickbare, farbige Stufen-Buttons (Client-
  Komponente mit Hidden-Input + HTML-form-Association) für Asset-
  Schutzbedarf und BIA-Schadenshöhe statt Dropdowns
- Prozess-Bearbeiten aufgeräumt: Sprungnavigation (Grunddaten/Assets/
  BIA), genau EIN Speichern-Button (Stammdaten + BIA in einer Action
  saveProcessAll, Felder per form-Attribut verbunden), Löschen ins
  ⋯-Überlaufmenü verschoben; Asset-Zuordnung als entfernbare Chips mit
  Segmentbalken

Verifiziert: kombiniertes Speichern (Name + BIA-Schadenshöhe in einem
Vorgang, Kritikalität neu berechnet), SegmentedRating-Wert korrekt
übernommen, ⋯-Menü mit Löschen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Martin
2026-07-02 20:00:39 +02:00
co-authored by Claude Opus 4.8
parent 5fb4b3cbf4
commit 430aaab024
8 changed files with 362 additions and 133 deletions
+4 -1
View File
@@ -175,7 +175,10 @@
"impactShort": "Schadenshöhe (C/I/A)",
"close": "Schließen",
"noAssets": "Noch keine Assets zugeordnet.",
"category": "Prozesskategorie"
"category": "Prozesskategorie",
"secMaster": "Grunddaten",
"secAssets": "Zugeordnete Assets",
"deleteProcess": "Prozess löschen"
},
"processRole": {
"PRIMARY": "Primär",
+4 -1
View File
@@ -175,7 +175,10 @@
"impactShort": "Damage level (C/I/A)",
"close": "Close",
"noAssets": "No assets assigned yet.",
"category": "Process category"
"category": "Process category",
"secMaster": "Basic data",
"secAssets": "Assigned assets",
"deleteProcess": "Delete process"
},
"processRole": {
"PRIMARY": "Primary",
+10 -9
View File
@@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { SegmentedRating } from "@/components/segmented-rating";
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "PERSON", "DATA"] as const;
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
const LEVELS = [1, 2, 3, 4] as const;
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
export async function AssetForm({
@@ -96,14 +96,15 @@ export async function AssetForm({
] as const
).map(([name, label, value]) => (
<div key={name}>
<Label htmlFor={name}>{label}</Label>
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1`}>
{LEVELS.map((l) => (
<option key={l} value={l}>
{l} {tLevel(String(l))}
</option>
))}
</select>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating
name={name}
defaultValue={value ?? 1}
low={tLevel("1")}
high={tLevel("4")}
/>
</div>
</div>
))}
</div>
+1 -1
View File
@@ -98,7 +98,7 @@ export async function AssetDetailModal({
<div className="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
<div className="flex items-center justify-between gap-2">
<b>{asset.name}</b>
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} />
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} labels />
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
{t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
+43 -11
View File
@@ -55,29 +55,61 @@ export function KpiCard({
);
}
/** Schutzbedarf/Schadenshöhe als drei Quadrate (C/I/A) wie im Mockup. */
const CIA_COLORS: Record<number, string> = {
// Segmentbalken-Farben je Stufe 14 (Referenz docs/ISMS-GUI-Verbesserungen-GEFIM.html)
const CIA_BAR_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 }) {
/** Ein Schutzziel als 4er-Segmentbalken, gefüllt bis `level`. */
function CiaSegment({ level, label }: { level: number; label?: string }) {
const color = CIA_BAR_COLORS[level] ?? CIA_BAR_COLORS[1];
return (
<span className="inline-flex gap-[3px]" title={`C${c} · I${i} · A${a}`}>
{[c, i, a].map((v, idx) => (
<span className="inline-flex flex-col items-center gap-1">
<span className="flex gap-[2px]">
{[1, 2, 3, 4].map((seg) => (
<span
key={idx}
key={seg}
className={cn(
"grid size-5 place-items-center rounded-md text-[10px] font-bold text-white",
CIA_COLORS[v] ?? CIA_COLORS[1]
"h-4 w-[7px] rounded-[2px]",
seg <= level ? color : "bg-[#e3e5ee]"
)}
>
{v}
</span>
/>
))}
</span>
{label && (
<small className="text-[9.5px] font-bold tracking-[.04em] text-muted-foreground">
{label}
</small>
)}
</span>
);
}
/**
* Schutzbedarf / Schadenshöhe als drei Segmentbalken (C/I/A). Mit `labels`
* werden VER/INT/VFB unter den Balken gezeigt (Detailkarten); ohne für
* kompakte Tabellenzellen.
*/
export function CiaBadge({
c,
i,
a,
labels = false,
}: {
c: number;
i: number;
a: number;
labels?: boolean;
}) {
return (
<span className="inline-flex items-center gap-2.5" title={`C${c} · I${i} · A${a}`}>
<CiaSegment level={c} label={labels ? "VER" : undefined} />
<CiaSegment level={i} label={labels ? "INT" : undefined} />
<CiaSegment level={a} label={labels ? "VFB" : undefined} />
</span>
);
}
+144 -70
View File
@@ -1,14 +1,13 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Pencil, Trash2, X } from "lucide-react";
import { MoreHorizontal, Pencil, Trash2, X } from "lucide-react";
import type { Prisma } from "@prisma/client";
import {
assignAsset,
createProcess,
deleteProcess,
saveBia,
saveProcessAll,
unassignAsset,
updateProcess,
} from "@/server/actions/processes";
import { ProcessForm } from "@/components/process-form";
import { Button } from "@/components/ui/button";
@@ -16,6 +15,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Modal } from "@/components/modal";
import { SegmentedRating } from "@/components/segmented-rating";
import { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
@@ -48,7 +48,6 @@ export type ProcessWithDetail = Prisma.ProcessGetPayload<{
};
}>;
const LEVELS = [1, 2, 3, 4] as const;
/** Anlegen als Popup (Stammdaten; Assets/BIA folgen im Bearbeiten-Popup). */
export async function ProcessCreateModal({
@@ -141,6 +140,7 @@ export async function ProcessDetailModal({
c={pa.asset.confidentiality}
i={pa.asset.integrity}
a={pa.asset.availability}
labels
/>
</div>
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
@@ -234,7 +234,12 @@ export async function ProcessDetailModal({
);
}
/** Bearbeiten im selben Popup: Stammdaten, Asset-Zuordnung, BIA, Löschen. */
/**
* Bearbeiten im selben Popup aufgeräumt (Referenz docs/ISMS-GUI-Verbesserungen):
* Sprungnavigation, genau EIN Speichern-Button (Stammdaten + BIA über die
* HTML-`form`-Association verbunden), Löschen im -Überlaufmenü. Die
* Asset-Zuordnung bleibt inkrementell (Chips add/remove).
*/
export async function ProcessEditModal({
process,
users,
@@ -247,20 +252,23 @@ export async function ProcessEditModal({
const t = await getTranslations("processes");
const tAssets = await getTranslations("assets");
const tRole = await getTranslations("processRole");
const tCrit = await getTranslations("criticality");
const tCat = await getTranslations("processCategory");
const tc = await getTranslations("common");
const bia = process.bia;
const FORM_ID = "process-edit-form";
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
const assetRow = (pa: ProcessWithDetail["processAssets"][number]) => (
<li key={pa.id} className="flex items-center gap-2">
{pa.asset.name}
const assetChip = (pa: ProcessWithDetail["processAssets"][number]) => (
<span
key={pa.id}
className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm"
>
<span className="font-medium">{pa.asset.name}</span>
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
<form action={unassignAsset.bind(null, process.id, pa.id)}>
<form action={unassignAsset.bind(null, process.id, pa.id)} className="flex">
<button
type="submit"
title={tc("remove")}
@@ -269,30 +277,83 @@ export async function ProcessEditModal({
<X className="size-3.5" />
</button>
</form>
</li>
</span>
);
const jump = [
{ href: "#p-grunddaten", label: t("secMaster") },
{ href: "#p-assets", label: t("secAssets") },
{ href: "#p-bia", label: t("bia") },
];
return (
<Modal
title={t("editTitle")}
sub={process.name}
headerExtra={
<details className="relative">
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden">
<MoreHorizontal className="size-4.5" />
</summary>
<div className="shadow-card absolute right-0 z-20 mt-1 w-52 rounded-xl border bg-card p-1.5">
<form action={deleteProcess.bind(null, process.id)}>
<button
type="submit"
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-4" /> {t("deleteProcess")}
</button>
</form>
</div>
</details>
}
closeHref={`/processes?detail=${process.id}`}
closeLabel={t("close")}
footer={
<>
<Button
variant="outline"
nativeButton={false}
render={<Link href={`/processes?detail=${process.id}`} />}
>
<div className="grid gap-6 p-5 md:grid-cols-2">
{/* Stammdaten */}
<form action={updateProcess.bind(null, process.id)} className="space-y-4">
{tc("cancel")}
</Button>
<Button type="submit" form={FORM_ID}>
{tc("save")}
</Button>
</>
}
>
{/* Sprungnavigation */}
<div className="sticky top-0 z-10 flex gap-1.5 border-b bg-card/95 px-5 py-2.5 backdrop-blur">
{jump.map((j) => (
<a
key={j.href}
href={j.href}
className="rounded-full border px-3 py-1 font-heading text-[12px] font-semibold text-muted-foreground hover:bg-secondary hover:text-foreground"
>
{j.label}
</a>
))}
</div>
{/* Die eigentliche <form> ist leer; alle Felder verweisen per form-Attribut
hierauf ein Speichern-Button, egal wo die Felder stehen. */}
<form id={FORM_ID} action={saveProcessAll.bind(null, process.id)} className="hidden" />
<div className="space-y-6 p-5">
{/* Grunddaten */}
<section id="p-grunddaten" className="scroll-mt-14">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secMaster")}</p>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label htmlFor="name">{t("name")}</Label>
<Input id="name" name="name" required defaultValue={process.name} className="mt-1" />
</div>
<div>
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
rows={3}
defaultValue={process.description ?? ""}
<Input
id="name"
name="name"
form={FORM_ID}
required
defaultValue={process.name}
className="mt-1"
/>
</div>
@@ -301,6 +362,7 @@ export async function ProcessEditModal({
<select
id="category"
name="category"
form={FORM_ID}
defaultValue={process.category}
className={`${selectClass} mt-1 w-full`}
>
@@ -311,11 +373,23 @@ export async function ProcessEditModal({
))}
</select>
</div>
<div className="md:col-span-2">
<Label htmlFor="description">{t("description")}</Label>
<Textarea
id="description"
name="description"
form={FORM_ID}
rows={2}
defaultValue={process.description ?? ""}
className="mt-1"
/>
</div>
<div>
<Label htmlFor="ownerId">{t("owner")}</Label>
<select
id="ownerId"
name="ownerId"
form={FORM_ID}
defaultValue={process.ownerId ?? ""}
className={`${selectClass} mt-1 w-full`}
>
@@ -327,26 +401,32 @@ export async function ProcessEditModal({
))}
</select>
</div>
<Button type="submit">{tc("save")}</Button>
</form>
</div>
</section>
{/* Asset-Zuordnung */}
<div className="space-y-4 text-sm">
<div>
{/* Assets (inkrementell) */}
<section id="p-assets" className="scroll-mt-14 border-t pt-5 text-sm">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secAssets")}</p>
<div className="mb-4">
<p className="font-medium">{t("primaryAssets")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
{primary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{primary.map(assetRow)}</ul>}
<p className="mb-2 text-xs text-muted-foreground">{t("primaryHint")}</p>
{primary.length === 0 ? (
<p className="text-muted-foreground">{tc("none")}</p>
) : (
<div className="flex flex-wrap gap-2">{primary.map(assetChip)}</div>
)}
</div>
<div>
<p className="font-medium">{t("secondaryAssets")}</p>
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
{secondary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{secondary.map(assetRow)}</ul>}
<p className="mb-2 text-xs text-muted-foreground">{t("secondaryHint")}</p>
{secondary.length === 0 ? (
<p className="text-muted-foreground">{tc("none")}</p>
) : (
<div className="flex flex-wrap gap-2">{secondary.map(assetChip)}</div>
)}
</div>
{availableAssets.length > 0 && (
<form
action={assignAsset.bind(null, process.id)}
className="flex flex-wrap gap-2 border-t pt-3"
>
<form action={assignAsset.bind(null, process.id)} className="mt-3 flex flex-wrap gap-2">
<select name="assetId" required className={`${selectClass} flex-1`}>
{availableAssets.map((a) => (
<option key={a.id} value={a.id}>
@@ -363,18 +443,11 @@ export async function ProcessEditModal({
</Button>
</form>
)}
</div>
</div>
</section>
{/* BIA */}
<div className="border-t p-5">
<p className="font-heading text-[15px] font-semibold">{t("bia")}</p>
{/* key erzwingt Remount nach dem Speichern — sonst behalten uncontrolled Inputs alte Werte */}
<form
key={bia?.updatedAt.toISOString() ?? "new"}
action={saveBia.bind(null, process.id)}
className="mt-3 space-y-4 text-sm"
>
{/* Business Impact Analyse */}
<section id="p-bia" className="scroll-mt-14 border-t pt-5 text-sm">
<p className="mb-3 font-heading text-[15px] font-semibold">{t("bia")}</p>
<div className="grid grid-cols-3 gap-4">
{(
[
@@ -387,11 +460,19 @@ export async function ProcessEditModal({
<Label htmlFor={name} title={hint}>
{label}
</Label>
<Input id={name} name={name} type="number" min={0} defaultValue={value ?? ""} className="mt-1" />
<Input
id={name}
name={name}
form={FORM_ID}
type="number"
min={0}
defaultValue={value ?? ""}
className="mt-1"
/>
</div>
))}
</div>
<fieldset>
<fieldset className="mt-4">
<legend className="font-medium">{t("impact")}</legend>
<div className="mt-2 grid grid-cols-3 gap-4">
{(
@@ -402,33 +483,26 @@ export async function ProcessEditModal({
] as const
).map(([name, label, value]) => (
<div key={name}>
<Label htmlFor={name}>{label}</Label>
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1 w-full`}>
{LEVELS.map((l) => (
<option key={l} value={l}>
{l} {tCrit(String(l))}
</option>
))}
</select>
<Label>{label}</Label>
<div className="mt-1">
<SegmentedRating name={name} defaultValue={value ?? 1} form={FORM_ID} />
</div>
</div>
))}
</div>
</fieldset>
<div>
<div className="mt-4">
<Label htmlFor="notes">{t("notes")}</Label>
<Textarea id="notes" name="notes" rows={3} defaultValue={bia?.notes ?? ""} className="mt-1" />
</div>
<div className="flex items-center justify-between">
<Button type="submit">{tc("save")}</Button>
</div>
</form>
<div className="mt-4 border-t pt-4">
<form action={deleteProcess.bind(null, process.id)}>
<Button type="submit" variant="destructive" size="sm">
<Trash2 className="size-4" /> {tc("delete")}
</Button>
</form>
<Textarea
id="notes"
name="notes"
form={FORM_ID}
rows={3}
defaultValue={bia?.notes ?? ""}
className="mt-1"
/>
</div>
</section>
</div>
</Modal>
);
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
// Farblogik Stufe 14 (Referenz docs/ISMS-GUI-Verbesserungen-GEFIM.html)
const LEVEL_STYLE: Record<number, { on: string; border: string }> = {
1: { on: "bg-[#9db2c9] text-white border-transparent", border: "border-[#9db2c9]" },
2: { on: "bg-[#5d8fbf] text-white border-transparent", border: "border-[#5d8fbf]" },
3: { on: "bg-[#e0982e] text-white border-transparent", border: "border-[#e0982e]" },
4: { on: "bg-[#d64c4c] text-white border-transparent", border: "border-[#d64c4c]" },
};
/**
* 14 anklickbare, farbige Stufen-Buttons mit genau einem aktiven Wert.
* Der Wert wird in einem Hidden-Input gehalten, damit die Komponente in
* server-gerenderten Formularen (Server Actions) funktioniert.
*/
export function SegmentedRating({
name,
defaultValue = 1,
low,
high,
form,
}: {
name: string;
defaultValue?: number;
low?: string;
high?: string;
// HTML form-Association: erlaubt das Feld außerhalb des <form> zu platzieren
form?: string;
}) {
const [value, setValue] = useState(defaultValue);
return (
<div>
<input type="hidden" name={name} value={value} form={form} />
<div className="flex gap-1.5">
{[1, 2, 3, 4].map((level) => {
const style = LEVEL_STYLE[level];
const active = value === level;
return (
<button
key={level}
type="button"
onClick={() => setValue(level)}
aria-pressed={active}
className={cn(
"flex-1 rounded-[9px] border py-2 font-heading text-[13px] font-bold transition-colors",
style.border,
active ? style.on : "bg-white text-muted-foreground hover:bg-muted/40"
)}
>
{level}
</button>
);
})}
</div>
{(low || high) && (
<div className="mt-1.5 flex justify-between text-[10.5px] text-muted-foreground">
<span>{low}</span>
<span>{high}</span>
</div>
)}
</div>
);
}
+49
View File
@@ -190,3 +190,52 @@ export async function saveBia(processId: string, formData: FormData) {
});
revalidatePath("/processes");
}
/**
* Kombiniertes Speichern für den aufgeräumten Bearbeiten-Dialog: Stammdaten
* und BIA in einem Vorgang (ein Speichern-Button). Asset-Zuordnung läuft
* weiter über die inkrementellen Aktionen assignAsset/unassignAsset.
*/
export async function saveProcessAll(processId: string, formData: FormData) {
const session = await requireSession();
requirePermission(session, "bia:write");
const db = dbForTenant(session.user.tenantId);
const before = await db.process.findUnique({
where: { id: processId },
include: { bia: true },
});
if (!before) throw new Error("Prozess nicht gefunden");
const master = parseProcessForm(formData);
await db.process.update({ where: { id: processId }, data: master });
const level = z.coerce.number().int().min(1).max(4);
const biaData = {
rtoHours: hours.parse(formData.get("rtoHours") ?? ""),
rpoHours: hours.parse(formData.get("rpoHours") ?? ""),
mtdHours: hours.parse(formData.get("mtdHours") ?? ""),
impactC: level.parse(formData.get("impactC")),
impactI: level.parse(formData.get("impactI")),
impactA: level.parse(formData.get("impactA")),
notes: (formData.get("notes") as string)?.trim() || null,
};
const criticality = Math.max(biaData.impactC, biaData.impactI, biaData.impactA);
await db.biaEntry.upsert({
where: { processId },
update: { ...biaData, criticality },
create: { ...biaData, criticality, processId, tenantId: session.user.tenantId },
});
await writeAuditLog({
tenantId: session.user.tenantId,
actorId: session.user.id,
action: "update",
entity: "process",
entityId: processId,
before,
after: { ...master, bia: { ...biaData, criticality } },
});
revalidatePath("/processes");
redirect(`/processes?detail=${processId}`);
}