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)",
|
"impactShort": "Schadenshöhe (C/I/A)",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"noAssets": "Noch keine Assets zugeordnet.",
|
"noAssets": "Noch keine Assets zugeordnet.",
|
||||||
"category": "Prozesskategorie"
|
"category": "Prozesskategorie",
|
||||||
|
"secMaster": "Grunddaten",
|
||||||
|
"secAssets": "Zugeordnete Assets",
|
||||||
|
"deleteProcess": "Prozess löschen"
|
||||||
},
|
},
|
||||||
"processRole": {
|
"processRole": {
|
||||||
"PRIMARY": "Primär",
|
"PRIMARY": "Primär",
|
||||||
|
|||||||
+4
-1
@@ -175,7 +175,10 @@
|
|||||||
"impactShort": "Damage level (C/I/A)",
|
"impactShort": "Damage level (C/I/A)",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"noAssets": "No assets assigned yet.",
|
"noAssets": "No assets assigned yet.",
|
||||||
"category": "Process category"
|
"category": "Process category",
|
||||||
|
"secMaster": "Basic data",
|
||||||
|
"secAssets": "Assigned assets",
|
||||||
|
"deleteProcess": "Delete process"
|
||||||
},
|
},
|
||||||
"processRole": {
|
"processRole": {
|
||||||
"PRIMARY": "Primary",
|
"PRIMARY": "Primary",
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { Button } from "@/components/ui/button";
|
|||||||
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 { SegmentedRating } from "@/components/segmented-rating";
|
||||||
|
|
||||||
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 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). */
|
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
|
||||||
export async function AssetForm({
|
export async function AssetForm({
|
||||||
@@ -96,14 +96,15 @@ export async function AssetForm({
|
|||||||
] as const
|
] as const
|
||||||
).map(([name, label, value]) => (
|
).map(([name, label, value]) => (
|
||||||
<div key={name}>
|
<div key={name}>
|
||||||
<Label htmlFor={name}>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1`}>
|
<div className="mt-1">
|
||||||
{LEVELS.map((l) => (
|
<SegmentedRating
|
||||||
<option key={l} value={l}>
|
name={name}
|
||||||
{l} — {tLevel(String(l))}
|
defaultValue={value ?? 1}
|
||||||
</option>
|
low={tLevel("1")}
|
||||||
))}
|
high={tLevel("4")}
|
||||||
</select>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</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="rounded-xl border border-l-[3px] border-l-[#5d52a3] bg-[#faf9fd] p-4">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<b>{asset.name}</b>
|
<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>
|
||||||
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
<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)}
|
{t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
|
||||||
|
|||||||
@@ -55,29 +55,61 @@ export function KpiCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Schutzbedarf/Schadenshöhe als drei Quadrate (C/I/A) wie im Mockup. */
|
// Segmentbalken-Farben je Stufe 1–4 (Referenz docs/ISMS-GUI-Verbesserungen-GEFIM.html)
|
||||||
const CIA_COLORS: Record<number, string> = {
|
const CIA_BAR_COLORS: Record<number, string> = {
|
||||||
1: "bg-[#9db2c9]",
|
1: "bg-[#9db2c9]",
|
||||||
2: "bg-[#5d8fbf]",
|
2: "bg-[#5d8fbf]",
|
||||||
3: "bg-[#e0982e]",
|
3: "bg-[#e0982e]",
|
||||||
4: "bg-[#d64c4c]",
|
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 (
|
return (
|
||||||
<span className="inline-flex gap-[3px]" title={`C${c} · I${i} · A${a}`}>
|
<span className="inline-flex flex-col items-center gap-1">
|
||||||
{[c, i, a].map((v, idx) => (
|
<span className="flex gap-[2px]">
|
||||||
|
{[1, 2, 3, 4].map((seg) => (
|
||||||
<span
|
<span
|
||||||
key={idx}
|
key={seg}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid size-5 place-items-center rounded-md text-[10px] font-bold text-white",
|
"h-4 w-[7px] rounded-[2px]",
|
||||||
CIA_COLORS[v] ?? CIA_COLORS[1]
|
seg <= level ? color : "bg-[#e3e5ee]"
|
||||||
)}
|
)}
|
||||||
>
|
/>
|
||||||
{v}
|
|
||||||
</span>
|
|
||||||
))}
|
))}
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { getTranslations } from "next-intl/server";
|
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 type { Prisma } from "@prisma/client";
|
||||||
import {
|
import {
|
||||||
assignAsset,
|
assignAsset,
|
||||||
createProcess,
|
createProcess,
|
||||||
deleteProcess,
|
deleteProcess,
|
||||||
saveBia,
|
saveProcessAll,
|
||||||
unassignAsset,
|
unassignAsset,
|
||||||
updateProcess,
|
|
||||||
} from "@/server/actions/processes";
|
} from "@/server/actions/processes";
|
||||||
import { ProcessForm } from "@/components/process-form";
|
import { ProcessForm } from "@/components/process-form";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -16,6 +15,7 @@ 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 { Modal } from "@/components/modal";
|
import { Modal } from "@/components/modal";
|
||||||
|
import { SegmentedRating } from "@/components/segmented-rating";
|
||||||
import { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
import { CiaBadge, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
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). */
|
/** Anlegen als Popup (Stammdaten; Assets/BIA folgen im Bearbeiten-Popup). */
|
||||||
export async function ProcessCreateModal({
|
export async function ProcessCreateModal({
|
||||||
@@ -141,6 +140,7 @@ export async function ProcessDetailModal({
|
|||||||
c={pa.asset.confidentiality}
|
c={pa.asset.confidentiality}
|
||||||
i={pa.asset.integrity}
|
i={pa.asset.integrity}
|
||||||
a={pa.asset.availability}
|
a={pa.asset.availability}
|
||||||
|
labels
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
<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({
|
export async function ProcessEditModal({
|
||||||
process,
|
process,
|
||||||
users,
|
users,
|
||||||
@@ -247,20 +252,23 @@ export async function ProcessEditModal({
|
|||||||
const t = await getTranslations("processes");
|
const t = await getTranslations("processes");
|
||||||
const tAssets = await getTranslations("assets");
|
const tAssets = await getTranslations("assets");
|
||||||
const tRole = await getTranslations("processRole");
|
const tRole = await getTranslations("processRole");
|
||||||
const tCrit = await getTranslations("criticality");
|
|
||||||
const tCat = await getTranslations("processCategory");
|
const tCat = await getTranslations("processCategory");
|
||||||
const tc = await getTranslations("common");
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
const bia = process.bia;
|
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 selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||||
|
|
||||||
const assetRow = (pa: ProcessWithDetail["processAssets"][number]) => (
|
const assetChip = (pa: ProcessWithDetail["processAssets"][number]) => (
|
||||||
<li key={pa.id} className="flex items-center gap-2">
|
<span
|
||||||
{pa.asset.name}
|
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} />
|
<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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
title={tc("remove")}
|
title={tc("remove")}
|
||||||
@@ -269,30 +277,83 @@ export async function ProcessEditModal({
|
|||||||
<X className="size-3.5" />
|
<X className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const jump = [
|
||||||
|
{ href: "#p-grunddaten", label: t("secMaster") },
|
||||||
|
{ href: "#p-assets", label: t("secAssets") },
|
||||||
|
{ href: "#p-bia", label: t("bia") },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={t("editTitle")}
|
title={t("editTitle")}
|
||||||
sub={process.name}
|
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}`}
|
closeHref={`/processes?detail=${process.id}`}
|
||||||
closeLabel={t("close")}
|
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">
|
{tc("cancel")}
|
||||||
{/* Stammdaten */}
|
</Button>
|
||||||
<form action={updateProcess.bind(null, process.id)} className="space-y-4">
|
<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>
|
<div>
|
||||||
<Label htmlFor="name">{t("name")}</Label>
|
<Label htmlFor="name">{t("name")}</Label>
|
||||||
<Input id="name" name="name" required defaultValue={process.name} className="mt-1" />
|
<Input
|
||||||
</div>
|
id="name"
|
||||||
<div>
|
name="name"
|
||||||
<Label htmlFor="description">{t("description")}</Label>
|
form={FORM_ID}
|
||||||
<Textarea
|
required
|
||||||
id="description"
|
defaultValue={process.name}
|
||||||
name="description"
|
|
||||||
rows={3}
|
|
||||||
defaultValue={process.description ?? ""}
|
|
||||||
className="mt-1"
|
className="mt-1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,6 +362,7 @@ export async function ProcessEditModal({
|
|||||||
<select
|
<select
|
||||||
id="category"
|
id="category"
|
||||||
name="category"
|
name="category"
|
||||||
|
form={FORM_ID}
|
||||||
defaultValue={process.category}
|
defaultValue={process.category}
|
||||||
className={`${selectClass} mt-1 w-full`}
|
className={`${selectClass} mt-1 w-full`}
|
||||||
>
|
>
|
||||||
@@ -311,11 +373,23 @@ export async function ProcessEditModal({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||||
<select
|
<select
|
||||||
id="ownerId"
|
id="ownerId"
|
||||||
name="ownerId"
|
name="ownerId"
|
||||||
|
form={FORM_ID}
|
||||||
defaultValue={process.ownerId ?? ""}
|
defaultValue={process.ownerId ?? ""}
|
||||||
className={`${selectClass} mt-1 w-full`}
|
className={`${selectClass} mt-1 w-full`}
|
||||||
>
|
>
|
||||||
@@ -327,26 +401,32 @@ export async function ProcessEditModal({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit">{tc("save")}</Button>
|
</div>
|
||||||
</form>
|
</section>
|
||||||
|
|
||||||
{/* Asset-Zuordnung */}
|
{/* Assets (inkrementell) */}
|
||||||
<div className="space-y-4 text-sm">
|
<section id="p-assets" className="scroll-mt-14 border-t pt-5 text-sm">
|
||||||
<div>
|
<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="font-medium">{t("primaryAssets")}</p>
|
||||||
<p className="mb-1 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
<p className="mb-2 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
||||||
{primary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{primary.map(assetRow)}</ul>}
|
{primary.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{tc("none")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">{primary.map(assetChip)}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">{t("secondaryAssets")}</p>
|
<p className="font-medium">{t("secondaryAssets")}</p>
|
||||||
<p className="mb-1 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
<p className="mb-2 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
||||||
{secondary.length === 0 ? <p>{tc("none")}</p> : <ul className="space-y-1">{secondary.map(assetRow)}</ul>}
|
{secondary.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">{tc("none")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">{secondary.map(assetChip)}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{availableAssets.length > 0 && (
|
{availableAssets.length > 0 && (
|
||||||
<form
|
<form action={assignAsset.bind(null, process.id)} className="mt-3 flex flex-wrap gap-2">
|
||||||
action={assignAsset.bind(null, process.id)}
|
|
||||||
className="flex flex-wrap gap-2 border-t pt-3"
|
|
||||||
>
|
|
||||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||||
{availableAssets.map((a) => (
|
{availableAssets.map((a) => (
|
||||||
<option key={a.id} value={a.id}>
|
<option key={a.id} value={a.id}>
|
||||||
@@ -363,18 +443,11 @@ export async function ProcessEditModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* BIA */}
|
{/* Business Impact Analyse */}
|
||||||
<div className="border-t p-5">
|
<section id="p-bia" className="scroll-mt-14 border-t pt-5 text-sm">
|
||||||
<p className="font-heading text-[15px] font-semibold">{t("bia")}</p>
|
<p className="mb-3 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"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
{(
|
{(
|
||||||
[
|
[
|
||||||
@@ -387,11 +460,19 @@ export async function ProcessEditModal({
|
|||||||
<Label htmlFor={name} title={hint}>
|
<Label htmlFor={name} title={hint}>
|
||||||
{label}
|
{label}
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<fieldset>
|
<fieldset className="mt-4">
|
||||||
<legend className="font-medium">{t("impact")}</legend>
|
<legend className="font-medium">{t("impact")}</legend>
|
||||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||||
{(
|
{(
|
||||||
@@ -402,33 +483,26 @@ export async function ProcessEditModal({
|
|||||||
] as const
|
] as const
|
||||||
).map(([name, label, value]) => (
|
).map(([name, label, value]) => (
|
||||||
<div key={name}>
|
<div key={name}>
|
||||||
<Label htmlFor={name}>{label}</Label>
|
<Label>{label}</Label>
|
||||||
<select id={name} name={name} defaultValue={value ?? 1} className={`${selectClass} mt-1 w-full`}>
|
<div className="mt-1">
|
||||||
{LEVELS.map((l) => (
|
<SegmentedRating name={name} defaultValue={value ?? 1} form={FORM_ID} />
|
||||||
<option key={l} value={l}>
|
</div>
|
||||||
{l} — {tCrit(String(l))}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<div>
|
<div className="mt-4">
|
||||||
<Label htmlFor="notes">{t("notes")}</Label>
|
<Label htmlFor="notes">{t("notes")}</Label>
|
||||||
<Textarea id="notes" name="notes" rows={3} defaultValue={bia?.notes ?? ""} className="mt-1" />
|
<Textarea
|
||||||
</div>
|
id="notes"
|
||||||
<div className="flex items-center justify-between">
|
name="notes"
|
||||||
<Button type="submit">{tc("save")}</Button>
|
form={FORM_ID}
|
||||||
</div>
|
rows={3}
|
||||||
</form>
|
defaultValue={bia?.notes ?? ""}
|
||||||
<div className="mt-4 border-t pt-4">
|
className="mt-1"
|
||||||
<form action={deleteProcess.bind(null, process.id)}>
|
/>
|
||||||
<Button type="submit" variant="destructive" size="sm">
|
|
||||||
<Trash2 className="size-4" /> {tc("delete")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</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");
|
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