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:
co-authored by
Claude Opus 4.8
parent
5fb4b3cbf4
commit
430aaab024
+4
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -55,28 +55,60 @@ 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 1–4 (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
|
||||
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 className="inline-flex flex-col items-center gap-1">
|
||||
<span className="flex gap-[2px]">
|
||||
{[1, 2, 3, 4].map((seg) => (
|
||||
<span
|
||||
key={seg}
|
||||
className={cn(
|
||||
"h-4 w-[7px] rounded-[2px]",
|
||||
seg <= level ? color : "bg-[#e3e5ee]"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+180
-106
@@ -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,84 +277,156 @@ 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}`} />}
|
||||
>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID}>
|
||||
{tc("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-6 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten */}
|
||||
<form action={updateProcess.bind(null, process.id)} className="space-y-4">
|
||||
<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 ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
defaultValue={process.category}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tCat(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select
|
||||
id="ownerId"
|
||||
name="ownerId"
|
||||
defaultValue={process.ownerId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
{/* 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>
|
||||
|
||||
{/* Asset-Zuordnung */}
|
||||
<div className="space-y-4 text-sm">
|
||||
<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"
|
||||
form={FORM_ID}
|
||||
required
|
||||
defaultValue={process.name}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.category}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tCat(v)}
|
||||
</option>
|
||||
))}
|
||||
</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`}
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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" />
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
form={FORM_ID}
|
||||
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>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Farblogik Stufe 1–4 (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]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 1–4 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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user