Basis: Certvia dev@a48c5fb als Fundament für Craftvia
Unveränderter Stand von certvia/dev (a48c5fb) plus Craftvia-Spezifikation und Brandbook unter docs/craftvia/. ISMS-Module werden im Folgecommit entfernt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Serverseitige Modul-Durchsetzung für den Onboarding-Wizard (§3.4). */
|
||||
export default async function ModuleLayout({ children }: { children: React.ReactNode }) {
|
||||
await requireModule("onboarding");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Check } from "lucide-react";
|
||||
import type { ObjectReviewStatus } from "@prisma/client";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MODULE_KEYS } from "@/lib/modules";
|
||||
import { getStep, getVisibleSteps, type StepCtx, type StepKey } from "@/lib/onboarding/registry";
|
||||
import "@/lib/onboarding/register-steps"; // Dev-B: echte Schritt-Komponenten einklinken (nach Default-Registrierung)
|
||||
import { advanceTarget, firstIncompleteIndex, isAwaitingValidation, isValidated, progressPercent } from "@/lib/onboarding/state";
|
||||
import { advanceStep, rejectStep, resetStep, validateStep } from "@/server/actions/onboarding";
|
||||
import { BiaPopup } from "@/components/bia-popup";
|
||||
import { ProcessCreateModal, ProcessHouseDetailModal } from "@/components/process-modals";
|
||||
|
||||
const STATUS_TONE: Record<ObjectReviewStatus, "mut" | "warn" | "info" | "ok" | "risk"> = {
|
||||
offen: "mut",
|
||||
in_bearbeitung: "warn",
|
||||
zur_validierung: "info",
|
||||
validiert: "ok",
|
||||
zurueckgewiesen: "risk",
|
||||
};
|
||||
|
||||
/** Beschriftung der Bearbeiter-Aktion je Status (advance/rework); null = keine. */
|
||||
function advanceKey(status: ObjectReviewStatus): "start" | "submit" | "rework" | null {
|
||||
if (!advanceTarget(status)) return null;
|
||||
if (status === "offen") return "start";
|
||||
if (status === "zurueckgewiesen") return "rework";
|
||||
return "submit";
|
||||
}
|
||||
|
||||
export default async function OnboardingPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{
|
||||
step?: string;
|
||||
bia?: string;
|
||||
biaStep?: string;
|
||||
biaNew?: string;
|
||||
detail?: string;
|
||||
new?: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const t = await getTranslations("onboarding");
|
||||
const tc = await getTranslations("common");
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
const canValidate = hasPermission(session, "validate_objects");
|
||||
|
||||
// Sichtbarkeits-Kontext: alle Modul-Keys außer den deaktivierten (Default = aktiv).
|
||||
const moduleRows = await db.tenantModule.findMany();
|
||||
const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
|
||||
const ctx: StepCtx = { enabledModules: new Set(MODULE_KEYS.filter((k) => !disabled.has(k))) };
|
||||
const steps = getVisibleSteps(ctx);
|
||||
|
||||
const rows = await db.onboardingProgress.findMany();
|
||||
const byKey = new Map(rows.map((r) => [r.stepKey, r]));
|
||||
const statusOf = (k: StepKey) => byKey.get(k)?.status ?? "offen";
|
||||
|
||||
// Aktueller Schritt: angefragter (jeder Schritt ist zugänglich) sonst Resume-Punkt.
|
||||
const params = await searchParams;
|
||||
const requestedIdx = steps.findIndex((s) => s.key === params.step);
|
||||
const currentIdx = requestedIdx >= 0 ? requestedIdx : firstIncompleteIndex(steps, statusOf);
|
||||
const current = steps[currentIdx];
|
||||
const currentStatus = statusOf(current.key);
|
||||
const currentComment = byKey.get(current.key)?.reviewComment ?? null;
|
||||
const StepComponent = getStep(current.key)!.component;
|
||||
|
||||
const percent = progressPercent(steps, statusOf);
|
||||
const done = steps.filter((s) => isValidated(statusOf(s.key))).length;
|
||||
const advKey = advanceKey(currentStatus);
|
||||
const awaitingValidation = isAwaitingValidation(currentStatus);
|
||||
const prev = currentIdx > 0 ? steps[currentIdx - 1] : null;
|
||||
const next = currentIdx < steps.length - 1 ? steps[currentIdx + 1] : null;
|
||||
|
||||
// TISAX v4B: Prozesshaus-Overlays (Detail read-only / Anlegen). Zustand über
|
||||
// searchParams ?detail=/?new=1 — als Sibling über die Seite gelegt, die
|
||||
// Onboarding-Hauptseite bleibt unverändert.
|
||||
const PROC_BASE = "/onboarding?step=processes";
|
||||
const biaEnabled = ctx.enabledModules.has("bia");
|
||||
const canWriteProcess = canUse && hasPermission(session, "bia:write");
|
||||
|
||||
const detailProcess = params.detail
|
||||
? await db.process.findUnique({
|
||||
where: { id: params.detail },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
parent: { select: { name: true } },
|
||||
children: { select: { id: true, name: true } },
|
||||
bia: true,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const detailDeputy = detailProcess?.deputyOwnerId
|
||||
? await db.user.findUnique({ where: { id: detailProcess.deputyOwnerId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
const showCreate = Boolean(params.new) && canWriteProcess;
|
||||
let processUsers: { id: string; name: string }[] = [];
|
||||
let processOptions: { id: string; name: string }[] = [];
|
||||
if (showCreate) {
|
||||
[processUsers, processOptions] = await Promise.all([
|
||||
db.user.findMany({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
db.process.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
]);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("progress", { done, total: steps.length, percent })} />
|
||||
|
||||
<div className="mt-4 grid gap-5 lg:grid-cols-[264px_1fr]">
|
||||
{/* Stepper */}
|
||||
<ol className="shadow-card h-fit space-y-1 rounded-xl border bg-card p-2">
|
||||
{steps.map((s, i) => {
|
||||
const st = statusOf(s.key);
|
||||
const isCurrent = i === currentIdx;
|
||||
const inner = (
|
||||
<span className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm ${isCurrent ? "bg-[var(--surface-soft)] font-semibold" : ""}`}>
|
||||
<span className={`grid size-6 shrink-0 place-items-center rounded-full border text-[11px] ${isValidated(st) ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "text-muted-foreground"}`}>
|
||||
{isValidated(st) ? <Check className="size-3.5" /> : s.order}
|
||||
</span>
|
||||
<span className="flex-1 truncate">{t(s.title)}</span>
|
||||
<Pill tone={STATUS_TONE[st]}>{t(`status.${st}`)}</Pill>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<Link href={`/onboarding?step=${s.key}`} className="block hover:opacity-90">{inner}</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
|
||||
{/* Aktueller Schritt + Aktionen */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="font-heading text-lg font-semibold">
|
||||
{current.order}. {t(current.title)}
|
||||
</h2>
|
||||
<Pill tone={STATUS_TONE[currentStatus]}>{t(`status.${currentStatus}`)}</Pill>
|
||||
</div>
|
||||
|
||||
{currentStatus === "zurueckgewiesen" && (
|
||||
<div className="rounded-xl border border-[var(--risk-brd,var(--band-brd))] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
|
||||
<b>{t("rejectedTitle")}</b>
|
||||
{currentComment ? <p className="mt-1">{currentComment}</p> : <p className="mt-1">{t("rejectedNoComment")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StepComponent stepKey={current.key} status={currentStatus} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
{/* Bearbeiter-Aktion */}
|
||||
{canUse && advKey && (
|
||||
<form action={advanceStep.bind(null, current.key)}>
|
||||
<Button type="submit">{t(`actions.${advKey}`)}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Validator-Aktionen (nur zur_validierung + Recht) */}
|
||||
{awaitingValidation && canValidate && (
|
||||
<>
|
||||
<form action={validateStep.bind(null, current.key)}>
|
||||
<Button type="submit">{t("actions.validate")}</Button>
|
||||
</form>
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-9 cursor-pointer list-none items-center rounded-md border border-input px-3 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||||
{t("actions.reject")}
|
||||
</summary>
|
||||
<form action={rejectStep.bind(null, current.key)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
|
||||
<Textarea name="comment" rows={3} placeholder={t("rejectCommentPlaceholder")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{t("actions.reject")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
{awaitingValidation && !canValidate && (
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("awaitingValidation")}</span>
|
||||
)}
|
||||
|
||||
{canUse && currentStatus !== "offen" && (
|
||||
<form action={resetStep.bind(null, current.key)}>
|
||||
<Button type="submit" variant="outline">{t("actions.reset")}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{prev && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/onboarding?step=${prev.key}`} />}>
|
||||
{t("actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
{next && (
|
||||
<Button nativeButton={false} render={<Link href={`/onboarding?step=${next.key}`} />}>
|
||||
{t("actions.next")}
|
||||
</Button>
|
||||
)}
|
||||
{!next && isValidated(currentStatus) && <Pill tone="ok">{t("allDone")}</Pill>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isValidated(currentStatus) && (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("gateHint")}</p>
|
||||
)}
|
||||
{!canUse && <p className="text-[12.5px] text-muted-foreground">{tc("readOnly")}</p>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* TISAX v3A: geführtes BIA-Popup je Prozess (Prozesshaus-Kachel). Zustand über
|
||||
searchParams ?bia=<id>&biaStep=1..5 — nur bei aktivem BIA-Modul. */}
|
||||
{params.bia && biaEnabled && (
|
||||
<BiaPopup
|
||||
processId={params.bia}
|
||||
step={Number.parseInt(params.biaStep ?? "1", 10) || 1}
|
||||
newForm={params.biaNew}
|
||||
basePath="/onboarding?step=processes"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TISAX v4B: „Details" read-only als Overlay über dem Prozesshaus. */}
|
||||
{detailProcess && (
|
||||
<ProcessHouseDetailModal
|
||||
process={detailProcess}
|
||||
deputyName={detailDeputy?.name ?? null}
|
||||
closeHref={PROC_BASE}
|
||||
biaHref={biaEnabled ? `${PROC_BASE}&bia=${detailProcess.id}&biaStep=1` : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TISAX v4B: „Prozess anlegen" als Overlay (Abbrechen/Schließen kehrt sicher zurück). */}
|
||||
{showCreate && (
|
||||
<ProcessCreateModal
|
||||
users={processUsers}
|
||||
processes={processOptions}
|
||||
closeHref={PROC_BASE}
|
||||
returnTo="house"
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Link from "next/link";
|
||||
import { Boxes } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { createAssetGapTasks } from "@/server/actions/onboarding";
|
||||
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
|
||||
import { assetTypeLabel } from "@/lib/asset-labels";
|
||||
|
||||
/**
|
||||
* Asset-Schritt (Schritt 5, Story A5-1). Nutzt das bestehende Assetinventar/BIA
|
||||
* (keine Doppel-Datenhaltung): Kennzahlen-Übersicht + Lücken (ohne Eigentümer,
|
||||
* ohne Schutzbedarfsbewertung). Pflege erfolgt im Asset-Modul; Lücken lassen sich
|
||||
* als Aufgaben anlegen.
|
||||
*/
|
||||
export async function AssetsStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [total, withoutOwner, highProtection, unrated, inScope] = await Promise.all([
|
||||
db.asset.count(),
|
||||
db.asset.count({ where: { ownerId: null } }),
|
||||
db.asset.count({ where: { OR: [{ confidentiality: { gte: 3 } }, { integrity: { gte: 3 } }, { availability: { gte: 3 } }] } }),
|
||||
db.asset.count({ where: { confidentiality: 1, integrity: 1, availability: 1 } }),
|
||||
db.asset.count({ where: { processAssets: { some: {} } } }),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Assets im Inventar", value: total },
|
||||
{ label: "hoher Schutzbedarf", value: highProtection },
|
||||
{ label: "in Prozessen (BIA)", value: inScope },
|
||||
];
|
||||
const gaps = [
|
||||
{ n: withoutOwner, label: "ohne Eigentümer", origin: "wizard:asset-no-owner" },
|
||||
{ n: unrated, label: "ohne Schutzbedarfsbewertung (C/I/A)", origin: "wizard:asset-unrated" },
|
||||
].filter((g) => g.n > 0);
|
||||
|
||||
// Rückmeldung: welche Lücken bereits eine (offene) Aufgabe haben (idempotent, Origin-Abgleich).
|
||||
const gapTasks = await db.task.findMany({
|
||||
where: { origin: { in: gaps.map((g) => g.origin) }, status: { in: ["PROPOSED", "OPEN"] } },
|
||||
select: { origin: true },
|
||||
});
|
||||
const withTask = new Set(gapTasks.map((t) => t.origin));
|
||||
const openGaps = gaps.filter((g) => !withTask.has(g.origin)).length;
|
||||
|
||||
// M2: vorgeschlagene Träger-Asset-Typen (SECONDARY) je übernommenem Katalog-Prozess.
|
||||
const suggestions = await getProcessCatalogSuggestions();
|
||||
const carrierSuggestions = suggestions
|
||||
.map((s) => ({
|
||||
...s,
|
||||
carriers: s.suggestedAssetTypes.filter((t) => t !== "INFORMATION" && t !== "DATA"),
|
||||
}))
|
||||
.filter((s) => s.carriers.length > 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Assetinventar</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<Boxes className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Der Wizard nutzt das bestehende Assetinventar (Schutzbedarf C/I/A, Eigentümer, BIA) —
|
||||
keine doppelte Datenhaltung. Pflege im Asset-Modul.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
|
||||
{canUse && (
|
||||
<form action={createAssetGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={openGaps === 0}>Prüfen & Aufgaben anlegen{openGaps ? ` (${openGaps})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Keine offensichtlichen Lücken (alle Assets mit Eigentümer und Schutzbedarf).</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={withTask.has(g.origin) ? "ok" : "warn"}>{withTask.has(g.origin) ? "Aufgabe angelegt" : "Lücke"}</Pill>
|
||||
<span><b>{g.n}</b> Asset(s) {g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Die Aufgabe stößt die Nacharbeit an; die Lücke selbst schließt sich erst, wenn im Asset-Modul Eigentümer bzw. Schutzbedarf gepflegt sind.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{carrierSuggestions.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Träger-Vorschläge je Prozess (Katalog)</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Typische Träger-Assets (Systeme, Anwendungen, Standorte, Dienstleister …), auf denen
|
||||
die primären Informationswerte dieser Prozesse liegen. Als Orientierung für die
|
||||
Erfassung im Prozess/Asset-Modul.
|
||||
</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{carrierSuggestions.map((s) => (
|
||||
<li key={s.processId} className="border-b pb-2 text-[12.5px] last:border-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1 font-medium">{s.processName}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/processes?detail=${s.processId}`} />}
|
||||
>
|
||||
Träger erfassen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{s.carriers.map((t) => (
|
||||
<Pill key={t} tone="mut">{assetTypeLabel(t)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { SECTION_TITLES, WIZARD_QUESTIONS, type WizardQuestion, type WizardSection } from "@/lib/onboarding/questions";
|
||||
import { deriveContext } from "@/lib/onboarding/facts";
|
||||
import { evaluateCondition } from "@/lib/rules/engine";
|
||||
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
|
||||
import { saveFacts } from "@/server/actions/onboarding-facts";
|
||||
|
||||
const selectCls = "h-8 w-full max-w-xs rounded-md border bg-background px-2 text-sm";
|
||||
const SECTIONS: WizardSection[] = ["A", "B", "C", "D", "E", "F"];
|
||||
|
||||
function QuestionField({ q, value }: { q: WizardQuestion; value: string | boolean }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-[13px] font-medium">{q.label}</span>
|
||||
{q.help && <span className="mt-0.5 block text-[11.5px] text-muted-foreground">{q.help}</span>}
|
||||
<span className="mt-1 block">
|
||||
{q.type === "boolean" ? (
|
||||
<select name={q.id} defaultValue={String(value === true)} className={selectCls}>
|
||||
<option value="true">Ja</option>
|
||||
<option value="false">Nein</option>
|
||||
</select>
|
||||
) : q.type === "select" ? (
|
||||
<select name={q.id} defaultValue={String(value)} className={selectCls}>
|
||||
{q.choices?.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<Input name={q.id} defaultValue={String(value)} className="h-8 max-w-xs" />
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fragebogen (Story B3, Schritt 2 „context"). Dynamischer, bedingter Katalog A–F
|
||||
* (C2). Antworten werden als WizardFacts gespeichert; die Wirkung (Flags, Aufgaben)
|
||||
* propagiert die saveFacts-Action über die B2-Engine. Bedingte Sichtbarkeit wird
|
||||
* serverseitig aus den abgeleiteten Flags/Antworten berechnet.
|
||||
*/
|
||||
export async function ContextStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [facts, scope] = await Promise.all([db.wizardFact.findMany(), db.wizardScope.findFirst()]);
|
||||
// Schutzbedarf-Flags zentral aus dem Assessment-Level (A2-1); Prüfziele zentral aus
|
||||
// WizardScope (Scoping, A2-2) — beides nicht aus dem Fragebogen (keine Doppelquelle).
|
||||
const level = await getAssessmentLevel(db);
|
||||
const ctx = deriveContext(facts, protectionFlags(level), scope?.pruefziele ?? ["informationssicherheit"]);
|
||||
const current = new Map(facts.map((f) => [f.key, f.value]));
|
||||
|
||||
const valueOf = (q: WizardQuestion): string | boolean =>
|
||||
current.has(q.id) ? (current.get(q.id) as string | boolean) : (q.default ?? (q.type === "boolean" ? false : ""));
|
||||
|
||||
const visible = WIZARD_QUESTIONS.filter((q) => !q.showWhen || evaluateCondition(q.showWhen, ctx));
|
||||
const activeFlags = Object.keys(ctx.flags).filter((k) => ctx.flags[k]).sort();
|
||||
|
||||
return (
|
||||
<form action={saveFacts} className="space-y-4">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Antworten werden als wiederverwendbare Fakten gespeichert. Sie steuern Flags, Klauseln, Controls und
|
||||
Aufgaben-Vorschläge. Organisation, Rollen und Schutzbedarf-Zentralwerte werden in den Einstellungen gepflegt.
|
||||
</p>
|
||||
|
||||
{SECTIONS.map((sec) => {
|
||||
const qs = visible.filter((q) => q.section === sec);
|
||||
if (qs.length === 0) return null;
|
||||
return (
|
||||
<fieldset key={sec} className="rounded-xl border bg-card p-4">
|
||||
<legend className="px-1 text-[13px] font-semibold">{SECTION_TITLES[sec]}</legend>
|
||||
<div className="mt-2 grid gap-3 sm:grid-cols-2">
|
||||
{qs.map((q) => <QuestionField key={q.id} q={q} value={valueOf(q)} />)}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-3">
|
||||
{canUse && <Button type="submit">Antworten speichern</Button>}
|
||||
<span className="flex flex-wrap items-center gap-1 text-[12px] text-muted-foreground">
|
||||
Abgeleitete Flags:
|
||||
{activeFlags.length ? activeFlags.map((f) => <Pill key={f} tone="info">{f}</Pill>) : <span>—</span>}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck, ListChecks } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { controlTitle } from "@/lib/control-titles";
|
||||
import { policyTitles, verfahrenTitles } from "@/lib/policy-titles";
|
||||
import { MATURITY_LABEL, type EvidenceStatus } from "@/lib/maturity";
|
||||
import { buildControlRows, type ControlRow, type HintWithStatus } from "@/server/soa-context";
|
||||
import { confirmControlMaturity, confirmAllSuggestions, createControlGapTasks, setImplementationStatus, createImplementationTask } from "@/server/actions/soa";
|
||||
|
||||
/**
|
||||
* Control-Assessment-Schritt (Schritt 7, Story A7-1/A7-2). Je In-Scope-Control: erwartete
|
||||
* Belege (Richtlinie/Verfahren/Asset/Risiko), abgeleiteter Belegstatus, regelbasierter
|
||||
* Reifegrad-Vorschlag (C5 §2), Zielreifegrad (C5 §3) und offene Punkte (C5 §4). Der
|
||||
* Bearbeiter bestätigt/überschreibt den Reifegrad (Pflicht); Lücken werden zu Aufgaben.
|
||||
*/
|
||||
|
||||
const MATURITY_TONE = ["risk", "orange", "info", "ok"] as const;
|
||||
const STATUS_LABEL: Record<EvidenceStatus, string> = { fehlt: "fehlt", verknuepft: "verknüpft", validiert: "validiert" };
|
||||
const STATUS_TONE: Record<EvidenceStatus, "ok" | "warn" | "mut"> = { fehlt: "mut", verknuepft: "warn", validiert: "ok" };
|
||||
|
||||
const IMPL_STATUSES = ["offen", "in_umsetzung", "erledigt"] as const;
|
||||
const IMPL_LABEL: Record<string, string> = { offen: "offen", in_umsetzung: "in Umsetzung", erledigt: "erledigt" };
|
||||
const IMPL_TONE: Record<string, "mut" | "warn" | "ok"> = { offen: "mut", in_umsetzung: "warn", erledigt: "ok" };
|
||||
const selectCls = "rounded-md border bg-[var(--surface-soft)] px-2 py-1 text-[11.5px]";
|
||||
|
||||
/** Umsetzungshinweise (Spiegelstriche) je Control: Status dokumentieren + Aufgabe erzeugen (#10). */
|
||||
function HintList({ hints, canUse }: { hints: HintWithStatus[]; canUse: boolean }) {
|
||||
const done = hints.filter((h) => h.status === "erledigt").length;
|
||||
return (
|
||||
<details className="mt-1.5">
|
||||
<summary className="cursor-pointer text-[11.5px] text-muted-foreground">
|
||||
Umsetzungshinweise ({done}/{hints.length} erledigt)
|
||||
</summary>
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{hints.map((h) => (
|
||||
<li key={h.hint.reqId} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone={IMPL_TONE[h.status] ?? "mut"}>{IMPL_LABEL[h.status] ?? h.status}</Pill>
|
||||
<span className="text-[12px] font-medium">{h.hint.requirement}</span>
|
||||
</div>
|
||||
<dl className="mt-1 grid gap-0.5 text-[11px] text-muted-foreground sm:grid-cols-3">
|
||||
<div><b>Organisatorisch:</b> {h.hint.organisational}</div>
|
||||
<div><b>Technisch:</b> {h.hint.technical}</div>
|
||||
<div><b>Nachweise:</b> {h.hint.evidence}</div>
|
||||
</dl>
|
||||
{canUse && (
|
||||
<div className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<form action={setImplementationStatus.bind(null, h.hint.reqId)} className="flex flex-wrap items-end gap-2">
|
||||
<select name="status" defaultValue={h.status} className={`${selectCls} mt-1`}>
|
||||
{IMPL_STATUSES.map((s) => <option key={s} value={s}>{IMPL_LABEL[s]}</option>)}
|
||||
</select>
|
||||
<Input name="note" defaultValue={h.note ?? ""} placeholder="Notiz / Nachweisverweis" className="h-7 w-56 text-[11.5px]" />
|
||||
<Button type="submit" size="sm" variant="outline">Speichern</Button>
|
||||
</form>
|
||||
<form action={createImplementationTask.bind(null, h.hint.reqId)}>
|
||||
<Button type="submit" size="sm" variant="ghost">Aufgabe</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function ControlLine({ row, hints, canUse }: { row: ControlRow; hints: HintWithStatus[]; canUse: boolean }) {
|
||||
const { spec, evidence, suggestion, target, gaps, confirmed } = row;
|
||||
const belowTarget = (confirmed ?? suggestion.value) < target;
|
||||
const implComplete = hints.length > 0 && hints.every((h) => h.status === "erledigt");
|
||||
return (
|
||||
<li className="border-b py-2.5 last:border-0">
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{row.control}</span>
|
||||
<span className="text-[12.5px] font-medium">{controlTitle(row.control)}</span>
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
{implComplete && <Pill tone="ok">Nachweis ✓</Pill>}
|
||||
<Pill tone={MATURITY_TONE[suggestion.value]}>Vorschlag {suggestion.value}</Pill>
|
||||
{confirmed !== null && <Pill tone={MATURITY_TONE[confirmed as 0 | 1 | 2 | 3]}>bestätigt {confirmed}</Pill>}
|
||||
<Pill tone={belowTarget ? "warn" : "ok"}>Ziel {target}</Pill>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground" title={suggestion.reason}>
|
||||
{spec.policy.length > 0 && (
|
||||
<span className="flex items-center gap-1">Richtlinie: {policyTitles(spec.policy)} <Pill tone={STATUS_TONE[evidence.policy]}>{STATUS_LABEL[evidence.policy]}</Pill></span>
|
||||
)}
|
||||
{spec.verfahren.length > 0 && (
|
||||
<span className="flex items-center gap-1">· Verfahren: {verfahrenTitles(spec.verfahren)} <Pill tone={STATUS_TONE[evidence.verfahren]}>{STATUS_LABEL[evidence.verfahren]}</Pill></span>
|
||||
)}
|
||||
{spec.needsAsset && <span>· Asset {evidence.assetLinked ? "✓" : "—"}</span>}
|
||||
{spec.needsRisk && <span>· Risiko {evidence.riskLinked ? "✓" : "—"}</span>}
|
||||
</div>
|
||||
{gaps.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{gaps.map((g, i) => (
|
||||
<Pill key={i} tone={g.kind === "widerspruch" ? "risk" : "warn"}>{g.missing}</Pill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hints.length > 0 && <HintList hints={hints} canUse={canUse} />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ControlsStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const { rows, level, implementation } = await buildControlRows(db, session.user.tenantId);
|
||||
const hintsFor = (control: string) => implementation.hintsByControl.get(control) ?? [];
|
||||
const confirmedCount = rows.filter((r) => r.confirmed !== null).length;
|
||||
const openGapCount = rows.reduce((n, r) => n + r.gaps.length, 0);
|
||||
const unconfirmed = rows.filter((r) => r.confirmed === null);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Controls im Scope", value: rows.length },
|
||||
{ label: "bestätigt", value: confirmedCount },
|
||||
{ label: "Umsetzung nachgewiesen", value: implementation.completeControls.size },
|
||||
{ label: "offene Punkte", value: openGapCount },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="flex items-center gap-2 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> Control-Assessment ({level})</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies" />}>
|
||||
<ListChecks className="size-4" /> Richtlinien
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-4">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Der Reifegrad-Vorschlag (0–3) wird regelbasiert aus dem Belegstand der Richtlinien-/Verfahrensdokumente,
|
||||
des Inventars und des Risikoregisters hergeleitet (VDA-ISA, C5). Er ist nicht bindend — die Bestätigung
|
||||
bzw. Überschreibung durch den Bearbeiter ist Pflicht. Je Control lassen sich unten die Umsetzungshinweise
|
||||
(Spiegelstriche) dokumentieren; sind alle erledigt, gilt der Wirksamkeitsnachweis als erbracht und der
|
||||
Vorschlag steigt auf Grad 3. Offene Punkte lassen sich je Hinweis zur Aufgabe machen.
|
||||
</p>
|
||||
{canUse && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<form action={confirmAllSuggestions}>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={unconfirmed.length === 0}>
|
||||
Vorschläge übernehmen{unconfirmed.length ? ` (${unconfirmed.length})` : ""}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={createControlGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Gap-Aufgaben anlegen{openGapCount ? ` (${openGapCount})` : ""}</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canUse && (
|
||||
<form action={confirmControlMaturity} className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Reifegrad bestätigen / überschreiben</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Absenkung unter den Vorschlag erfordert eine Begründung (C5 §4.6).</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-[2fr_1fr]">
|
||||
<label className="text-[12px]">Control
|
||||
<select name="control" required className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
|
||||
{rows.map((r) => (
|
||||
<option key={r.control} value={r.control}>{r.control} — {controlTitle(r.control)} (Vorschlag {r.suggestion.value})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-[12px]">Reifegrad
|
||||
<select name="value" required defaultValue="" className="mt-1 w-full rounded-lg border bg-[var(--surface-soft)] px-2 py-1.5 text-[12.5px]">
|
||||
<option value="" disabled>wählen…</option>
|
||||
{[0, 1, 2, 3].map((v) => <option key={v} value={v}>{MATURITY_LABEL[v as 0 | 1 | 2 | 3]}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="mt-3 block text-[12px]">Begründung (bei Absenkung)
|
||||
<Textarea name="justification" rows={2} className="mt-1" placeholder="z. B. kompensierende Maßnahme, Restrisiko akzeptiert …" />
|
||||
</label>
|
||||
<div className="mt-3"><Button type="submit" size="sm">Bestätigen</Button></div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Controls im Assessment-Scope</span>
|
||||
<ul className="mt-2">
|
||||
{rows.map((r) => <ControlLine key={r.control} row={r} hints={hintsFor(r.control)} canUse={canUse} />)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { requirePermission } from "@/server/rbac";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
|
||||
/**
|
||||
* Risikoakzeptanzkriterien & Bewertungsskala pflegen (Ebene 1 „Fundament").
|
||||
*
|
||||
* Schreibt die drei zentralen Konfigurationsmodelle des Risiko-Fundaments:
|
||||
* - `RiskMatrixClass` : Risikoklassen mit Schwelle (maxScore) + Freigabeinstanz (acceptance)
|
||||
* - `RiskEwLevel` : Eintrittswahrscheinlichkeits-Skala (Stufe/Label/Definition)
|
||||
* - `RiskDamageDimension` : Schadensdimensionen (C/I/A-Bewertungsgrundlage) mit 4 Stufen
|
||||
*
|
||||
* EINE Pflegestelle: Der Wizard-Popup (Schritt 5) und die Einstellungen (/settings) rufen
|
||||
* dieselbe Action und schreiben dieselben Werte. Speicherstrategie: pro Mandant ersetzen
|
||||
* (delete + create in einer Transaktion) — analog zum verwalteten Import.
|
||||
*/
|
||||
|
||||
const toneSchema = z.enum(["ok", "warn", "orange", "risk"]);
|
||||
|
||||
const payloadSchema = z.object({
|
||||
matrixClasses: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Name der Risikoklasse fehlt."),
|
||||
maxScore: z.coerce.number().int().min(1).max(999),
|
||||
acceptance: z.string().trim().min(1, "Freigabeinstanz fehlt."),
|
||||
tone: toneSchema,
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
ewLevels: z
|
||||
.array(
|
||||
z.object({
|
||||
level: z.coerce.number().int().min(1).max(9),
|
||||
label: z.string().trim().min(1, "Label der Wahrscheinlichkeitsstufe fehlt."),
|
||||
definition: z.string().trim().default(""),
|
||||
}),
|
||||
)
|
||||
.max(9),
|
||||
damageDims: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().trim().min(1, "Name der Schadensdimension fehlt."),
|
||||
levels: z.object({
|
||||
"1": z.string().trim().default(""),
|
||||
"2": z.string().trim().default(""),
|
||||
"3": z.string().trim().default(""),
|
||||
"4": z.string().trim().default(""),
|
||||
"5": z.string().trim().default(""),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.max(20),
|
||||
});
|
||||
|
||||
export async function saveRiskCriteria(formData: FormData) {
|
||||
const session = await requireSession();
|
||||
// Fundament-Pflege im Wizard und in den Einstellungen — beide Einstiege haben onboarding:use
|
||||
// (Mandanten-Admin und ISB). Autoritative Prüfung (wirft ForbiddenError bei fehlendem Recht).
|
||||
requirePermission(session, "onboarding:use");
|
||||
const tenantId = session.user.tenantId;
|
||||
const db = dbForTenant(tenantId);
|
||||
|
||||
const raw = String(formData.get("payload") ?? "");
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error("Ungültige Kriteriendaten.");
|
||||
}
|
||||
const { matrixClasses, ewLevels, damageDims } = payloadSchema.parse(parsedJson);
|
||||
|
||||
await db.$transaction([
|
||||
db.riskMatrixClass.deleteMany({}),
|
||||
db.riskMatrixClass.createMany({
|
||||
data: matrixClasses.map((c, i) => ({
|
||||
tenantId,
|
||||
name: c.name,
|
||||
maxScore: c.maxScore,
|
||||
acceptance: c.acceptance,
|
||||
tone: c.tone,
|
||||
orderIdx: i,
|
||||
})),
|
||||
}),
|
||||
db.riskEwLevel.deleteMany({}),
|
||||
db.riskEwLevel.createMany({
|
||||
data: ewLevels.map((e) => ({ tenantId, level: e.level, label: e.label, definition: e.definition })),
|
||||
}),
|
||||
db.riskDamageDimension.deleteMany({}),
|
||||
db.riskDamageDimension.createMany({
|
||||
data: damageDims.map((d, i) => ({ tenantId, name: d.name, levels: d.levels, orderIdx: i })),
|
||||
}),
|
||||
]);
|
||||
|
||||
await writeAuditLog({
|
||||
tenantId,
|
||||
actorId: session.user.id,
|
||||
action: "update",
|
||||
entity: "risk_criteria",
|
||||
after: {
|
||||
matrixClasses: matrixClasses.length,
|
||||
ewLevels: ewLevels.length,
|
||||
damageDims: damageDims.length,
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath("/onboarding");
|
||||
revalidatePath("/settings");
|
||||
revalidatePath("/risks");
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Popup } from "../roles/popup";
|
||||
import {
|
||||
CriteriaEditor,
|
||||
type MatrixClassRow,
|
||||
type EwLevelRow,
|
||||
type DamageDimRow,
|
||||
} from "./criteria-editor";
|
||||
|
||||
/**
|
||||
* „Kriterien bearbeiten“ direkt im Wizard (Schritt 5). Öffnet den gemeinsamen
|
||||
* `CriteriaEditor` im Popup (Optik wie Risiken-Popup). Schreibt über dieselbe
|
||||
* Action wie die Einstellungen — eine Pflegestelle.
|
||||
*/
|
||||
export function CriteriaEditDialog({
|
||||
matrixClasses,
|
||||
ewLevels,
|
||||
damageDims,
|
||||
}: {
|
||||
matrixClasses: MatrixClassRow[];
|
||||
ewLevels: EwLevelRow[];
|
||||
damageDims: DamageDimRow[];
|
||||
}) {
|
||||
return (
|
||||
<Popup
|
||||
triggerLabel="Kriterien bearbeiten"
|
||||
triggerIcon={<Pencil className="size-4" />}
|
||||
triggerVariant="default"
|
||||
triggerSize="sm"
|
||||
title="Risikoakzeptanzkriterien & Bewertungsskala bearbeiten"
|
||||
sub="Risikoklassen, Eintrittswahrscheinlichkeit und Schadensdimensionen (C/I/A) — zentral gepflegt."
|
||||
maxWidthClass="max-w-3xl"
|
||||
>
|
||||
{(close) => (
|
||||
<div className="max-h-[70vh] overflow-y-auto p-5">
|
||||
<CriteriaEditor
|
||||
initialMatrixClasses={matrixClasses}
|
||||
initialEwLevels={ewLevels}
|
||||
initialDamageDims={damageDims}
|
||||
onSaved={close}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { Plus, Trash2, ShieldCheck, Scale, Activity } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { saveRiskCriteria } from "./actions";
|
||||
|
||||
export type Tone = "ok" | "warn" | "orange" | "risk";
|
||||
|
||||
export interface MatrixClassRow {
|
||||
name: string;
|
||||
maxScore: number;
|
||||
acceptance: string;
|
||||
tone: Tone;
|
||||
}
|
||||
export interface EwLevelRow {
|
||||
level: number;
|
||||
label: string;
|
||||
definition: string;
|
||||
}
|
||||
export interface DamageDimRow {
|
||||
name: string;
|
||||
levels: { "1": string; "2": string; "3": string; "4": string; "5": string };
|
||||
}
|
||||
|
||||
const TONE_OPTIONS: { value: Tone; label: string }[] = [
|
||||
{ value: "ok", label: "grün (niedrig)" },
|
||||
{ value: "warn", label: "gelb (mittel)" },
|
||||
{ value: "orange", label: "orange (hoch)" },
|
||||
{ value: "risk", label: "rot (kritisch)" },
|
||||
];
|
||||
|
||||
const inputCls = "h-8 text-[12.5px]";
|
||||
|
||||
/**
|
||||
* Gemeinsamer Editor für Risikoakzeptanzkriterien & Bewertungsskala. Wird sowohl im
|
||||
* Wizard-Popup (Schritt 5) als auch inline in den Einstellungen verwendet und schreibt
|
||||
* über dieselbe Server-Action `saveRiskCriteria` in dieselben Modelle (eine Pflegestelle).
|
||||
*/
|
||||
export function CriteriaEditor({
|
||||
initialMatrixClasses,
|
||||
initialEwLevels,
|
||||
initialDamageDims,
|
||||
onSaved,
|
||||
}: {
|
||||
initialMatrixClasses: MatrixClassRow[];
|
||||
initialEwLevels: EwLevelRow[];
|
||||
initialDamageDims: DamageDimRow[];
|
||||
onSaved?: () => void;
|
||||
}) {
|
||||
const [matrixClasses, setMatrixClasses] = useState<MatrixClassRow[]>(initialMatrixClasses);
|
||||
const [ewLevels, setEwLevels] = useState<EwLevelRow[]>(initialEwLevels);
|
||||
const [damageDims, setDamageDims] = useState<DamageDimRow[]>(initialDamageDims);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [pending, start] = useTransition();
|
||||
|
||||
function updateMatrix(idx: number, patch: Partial<MatrixClassRow>) {
|
||||
setMatrixClasses((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateEw(idx: number, patch: Partial<EwLevelRow>) {
|
||||
setEwLevels((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateDim(idx: number, patch: Partial<DamageDimRow>) {
|
||||
setDamageDims((rows) => rows.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
||||
}
|
||||
function updateDimLevel(idx: number, key: "1" | "2" | "3" | "4" | "5", value: string) {
|
||||
setDamageDims((rows) =>
|
||||
rows.map((r, i) => (i === idx ? { ...r, levels: { ...r.levels, [key]: value } } : r)),
|
||||
);
|
||||
}
|
||||
|
||||
function submit() {
|
||||
setError(null);
|
||||
setSaved(false);
|
||||
start(async () => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("payload", JSON.stringify({ matrixClasses, ewLevels, damageDims }));
|
||||
await saveRiskCriteria(fd);
|
||||
setSaved(true);
|
||||
onSaved?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Speichern fehlgeschlagen.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Risikoklassen / Akzeptanzkriterien */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<Scale className="size-4" /> Risikoklassen & Akzeptanz
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setMatrixClasses((r) => [...r, { name: "", maxScore: 0, acceptance: "", tone: "warn" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Klasse
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
Schwelle (bis Risikowert) und Freigabeinstanz je Risikoklasse.
|
||||
</p>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="grid grid-cols-[1fr_5rem_1.4fr_7rem_2rem] gap-2 px-1 text-[10.5px] font-medium text-muted-foreground max-sm:hidden">
|
||||
<span>Risikoklasse</span>
|
||||
<span>bis Wert</span>
|
||||
<span>Akzeptanz / Freigabeinstanz</span>
|
||||
<span>Farbe</span>
|
||||
<span />
|
||||
</div>
|
||||
{matrixClasses.map((c, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[1fr_5rem_1.4fr_7rem_2rem]">
|
||||
<Input
|
||||
value={c.name}
|
||||
onChange={(e) => updateMatrix(i, { name: e.target.value })}
|
||||
placeholder="Niedrig"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={c.maxScore}
|
||||
onChange={(e) => updateMatrix(i, { maxScore: Number(e.target.value) })}
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
value={c.acceptance}
|
||||
onChange={(e) => updateMatrix(i, { acceptance: e.target.value })}
|
||||
placeholder="Akzeptanz durch …"
|
||||
className={inputCls}
|
||||
/>
|
||||
<select
|
||||
value={c.tone}
|
||||
onChange={(e) => updateMatrix(i, { tone: e.target.value as Tone })}
|
||||
className="h-8 rounded-md border border-input bg-background px-2 text-[12px]"
|
||||
>
|
||||
{TONE_OPTIONS.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Klasse entfernen"
|
||||
onClick={() => setMatrixClasses((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{matrixClasses.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Risikoklassen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Eintrittswahrscheinlichkeit */}
|
||||
<section className="border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<Activity className="size-4" /> Eintrittswahrscheinlichkeit
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setEwLevels((r) => [...r, { level: r.length + 1, label: "", definition: "" }])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Stufe
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{ewLevels.map((e, i) => (
|
||||
<div key={i} className="grid grid-cols-1 gap-2 sm:grid-cols-[3.5rem_1fr_1.6fr_2rem]">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={e.level}
|
||||
onChange={(ev) => updateEw(i, { level: Number(ev.target.value) })}
|
||||
className={inputCls}
|
||||
aria-label="Stufe"
|
||||
/>
|
||||
<Input
|
||||
value={e.label}
|
||||
onChange={(ev) => updateEw(i, { label: ev.target.value })}
|
||||
placeholder="Label"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Input
|
||||
value={e.definition}
|
||||
onChange={(ev) => updateEw(i, { definition: ev.target.value })}
|
||||
placeholder="Definition"
|
||||
className={inputCls}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Stufe entfernen"
|
||||
onClick={() => setEwLevels((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{ewLevels.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Stufen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Schadensdimensionen (C/I/A-Bewertungsgrundlage) */}
|
||||
<section className="border-t pt-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold">
|
||||
<ShieldCheck className="size-4" /> Schadensdimensionen (C/I/A-Skala)
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
setDamageDims((r) => [
|
||||
...r,
|
||||
{ name: "", levels: { "1": "", "2": "", "3": "", "4": "", "5": "" } },
|
||||
])
|
||||
}
|
||||
>
|
||||
<Plus className="size-3.5" /> Dimension
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
Bewertungsgrundlage der Schadensstufen 1–5 je Dimension.
|
||||
</p>
|
||||
<div className="mt-2 space-y-2">
|
||||
{damageDims.map((d, i) => (
|
||||
<div key={i} className="rounded-lg border bg-[var(--surface-soft)] p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={d.name}
|
||||
onChange={(e) => updateDim(i, { name: e.target.value })}
|
||||
placeholder="Name der Dimension"
|
||||
className={`${inputCls} font-medium`}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
title="Dimension entfernen"
|
||||
onClick={() => setDamageDims((r) => r.filter((_, j) => j !== i))}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5">
|
||||
{(["1", "2", "3", "4", "5"] as const).map((k) => (
|
||||
<div key={k}>
|
||||
<span className="text-[10.5px] text-muted-foreground">Stufe {k}</span>
|
||||
<Input
|
||||
value={d.levels[k]}
|
||||
onChange={(e) => updateDimLevel(i, k, e.target.value)}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{damageDims.length === 0 && (
|
||||
<p className="text-[12px] text-muted-foreground">Noch keine Schadensdimensionen.</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
||||
<Button type="button" onClick={submit} disabled={pending}>
|
||||
{pending ? "Speichern …" : "Speichern"}
|
||||
</Button>
|
||||
{saved && !error && <span className="text-[12px] text-[var(--ok)]">Gespeichert.</span>}
|
||||
{error && <span className="text-[12px] text-destructive">{error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import Link from "next/link";
|
||||
import { Settings, ShieldCheck, Scale } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getAssessmentLevel } from "@/server/assessment-level";
|
||||
import { CriteriaEditDialog } from "./criteria-edit-dialog";
|
||||
import type { Tone } from "./criteria-editor";
|
||||
|
||||
/**
|
||||
* Risikoakzeptanzkriterien & C/I/A-Skala (Ebene 1 „Fundament", M1). Als Fundament-
|
||||
* Artefakt festgehalten: die zentrale Schutzbedarfsskala (C/I/A) und die
|
||||
* Risikoakzeptanzkriterien (Bewertungsmatrix + Akzeptanzinstanzen). Beides wird
|
||||
* zentral im Risiko-Modul/den Einstellungen gepflegt (keine Doppel-Datenhaltung) —
|
||||
* hier zusammengeführt, damit das Fundament vollständig und prüffähig ist.
|
||||
*/
|
||||
export async function CriteriaStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canEdit = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [level, damageDims, matrixClasses, ewLevels] = await Promise.all([
|
||||
getAssessmentLevel(db),
|
||||
db.riskDamageDimension.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.riskMatrixClass.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
db.riskEwLevel.findMany({ orderBy: { level: "asc" } }),
|
||||
]);
|
||||
const veryHigh = level === "AL3";
|
||||
|
||||
// Serialisierbare Editor-Daten (Popup + Einstellungen teilen sich denselben Editor).
|
||||
const lvl = (v: unknown, k: string) => {
|
||||
const rec = (v ?? {}) as Record<string, unknown>;
|
||||
return typeof rec[k] === "string" ? (rec[k] as string) : "";
|
||||
};
|
||||
const editorMatrix = matrixClasses.map((c) => ({
|
||||
name: c.name,
|
||||
maxScore: c.maxScore,
|
||||
acceptance: c.acceptance,
|
||||
tone: (["ok", "warn", "orange", "risk"].includes(c.tone) ? c.tone : "warn") as Tone,
|
||||
}));
|
||||
const editorEw = ewLevels.map((e) => ({ level: e.level, label: e.label, definition: e.definition }));
|
||||
const editorDims = damageDims.map((d) => ({
|
||||
name: d.name,
|
||||
levels: {
|
||||
"1": lvl(d.levels, "1"),
|
||||
"2": lvl(d.levels, "2"),
|
||||
"3": lvl(d.levels, "3"),
|
||||
"4": lvl(d.levels, "4"),
|
||||
"5": lvl(d.levels, "5"),
|
||||
},
|
||||
}));
|
||||
|
||||
// Standard-Schutzbedarfsstufen (C/I/A). „sehr hoch" nur bei AL3 (Assessment-Level).
|
||||
const ciaLevels = [
|
||||
{ key: "normal", label: "normal", desc: "Begrenzte Auswirkungen; Standard-Schutz genügt." },
|
||||
{ key: "hoch", label: "hoch", desc: "Beträchtliche Auswirkungen; erhöhter Schutzbedarf." },
|
||||
...(veryHigh ? [{ key: "sehr_hoch", label: "sehr hoch", desc: "Existenzbedrohende/katastrophale Auswirkungen; höchster Schutzbedarf." }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* C/I/A-Schutzbedarfsskala */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><ShieldCheck className="size-4" /> C/I/A-Schutzbedarfsskala</span>
|
||||
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Einheitliche Skala für Vertraulichkeit (C), Integrität (I) und Verfügbarkeit (A). Die Stufe
|
||||
„sehr hoch“ ist {veryHigh ? "durch das Assessment-Level AL3 aktiv" : "erst ab Assessment-Level AL3 relevant"}.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-3">
|
||||
{ciaLevels.map((l) => (
|
||||
<div key={l.key} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[13px] font-semibold">{l.label}</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">{l.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{damageDims.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="text-[11.5px] font-medium text-muted-foreground">Schadensdimensionen (Bewertungsgrundlage):</p>
|
||||
<ul className="mt-1 flex flex-wrap gap-1.5">
|
||||
{damageDims.map((d) => (
|
||||
<li key={d.id}><Pill tone="info">{d.name}</Pill></li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Risikoakzeptanzkriterien */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><Scale className="size-4" /> Risikoakzeptanzkriterien</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{canEdit && (
|
||||
<CriteriaEditDialog matrixClasses={editorMatrix} ewLevels={editorEw} damageDims={editorDims} />
|
||||
)}
|
||||
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
|
||||
<Settings className="size-3.5" /> in Einstellungen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matrixClasses.length > 0 ? (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11px] text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-medium">Risikoklasse</th>
|
||||
<th className="py-1.5 pr-3 font-medium">bis Risikowert</th>
|
||||
<th className="py-1.5 font-medium">Akzeptanz / Freigabeinstanz</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{matrixClasses.map((c) => (
|
||||
<tr key={c.id} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-3"><b>{c.name}</b></td>
|
||||
<td className="py-1.5 pr-3 tabular-nums">{c.maxScore}</td>
|
||||
<td className="py-1.5">{c.acceptance}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Noch keine Risikobewertungsmatrix hinterlegt. Die Akzeptanzkriterien (Schwellen und
|
||||
Freigabeinstanzen je Risikoklasse) werden zentral gepflegt und hier als Fundament-Artefakt gespiegelt.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{ewLevels.length > 0 && (
|
||||
<p className="mt-3 text-[11px] text-muted-foreground">
|
||||
Eintrittswahrscheinlichkeit: {ewLevels.map((e) => `${e.level} = ${e.label}`).join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Risikoeigner akzeptieren Restrisiken gemäß dieser Kriterien; Ausnahmen oberhalb der
|
||||
Akzeptanzschwelle erfordern eine dokumentierte Managemententscheidung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, useTransition } from "react";
|
||||
import { Search, Plus, Link2, AlertTriangle, Sparkles } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { InfoLabel } from "@prisma/client";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { infoLabelLabel } from "@/lib/asset-labels";
|
||||
import {
|
||||
searchAssets,
|
||||
addPrimaryInformation,
|
||||
linkPrimaryInformation,
|
||||
type AssetSuggestion,
|
||||
} from "@/server/actions/structure";
|
||||
import {
|
||||
nameSimilarity,
|
||||
normalizeAssetName,
|
||||
FUZZY_SIMILARITY_THRESHOLD,
|
||||
} from "@/lib/normalize-asset";
|
||||
|
||||
const INFO_LABELS: { value: string; label: string }[] = [
|
||||
{ value: "NONE", label: "ohne" },
|
||||
{ value: "INFO_HIGH", label: "Info hoch" },
|
||||
{ value: "INFO_VERY_HIGH", label: "Info sehr hoch" },
|
||||
{ value: "PROTOTYPE", label: "Prototyp" },
|
||||
{ value: "PERSONAL_DATA", label: "personenbezogen" },
|
||||
];
|
||||
|
||||
/**
|
||||
* M2 §2.1 — Combobox zur prozessgeführten Erfassung PRIMÄRER Informations-Assets
|
||||
* mit Dedup + Autovervollständigung.
|
||||
*
|
||||
* - As-you-type ruft `searchAssets` (server) → Vorschläge (type INFORMATION/DATA),
|
||||
* inkl. „bereits erfasst in Prozess X". Ein Klick VERKNÜPFT das bestehende Asset
|
||||
* (linkPrimaryInformation) statt ein Duplikat anzulegen.
|
||||
* - Weiche Fuzzy-Warnung: in-app Levenshtein (`nameSimilarity`) auf der
|
||||
* Kandidatenliste — warnt „Meintest du …?" VOR dem Anlegen.
|
||||
* - Exakt-Dedup erzwingt zusätzlich die DB (@@unique) im Schreibpfad.
|
||||
*/
|
||||
export function InformationCombobox({
|
||||
processId,
|
||||
suggestedLabels = [],
|
||||
hideType = false,
|
||||
hideLabel = false,
|
||||
}: {
|
||||
processId: string;
|
||||
/** Katalog-Vorschläge (suggestedInfoLabels) des Prozesses — als Ein-Klick-Defaults. */
|
||||
suggestedLabels?: InfoLabel[];
|
||||
/** BIA-Info-Schritt: Typ-Auswahl (Information/Daten) ausblenden — immer INFORMATION. */
|
||||
hideType?: boolean;
|
||||
/** BIA-Info-Schritt: Klassifizierungs-Dropdown ausblenden — Schutzstufe folgt aus C/I/A. */
|
||||
hideLabel?: boolean;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [type, setType] = useState<"INFORMATION" | "DATA">("INFORMATION");
|
||||
const [label, setLabel] = useState("NONE");
|
||||
const [results, setResults] = useState<AssetSuggestion[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searching, startSearch] = useTransition();
|
||||
const [saving, startSave] = useTransition();
|
||||
const seq = useRef(0);
|
||||
|
||||
// Debounced As-you-type-Suche gegen das Tenant-Asset-Register. Das Leeren/Suchen
|
||||
// läuft im Timeout-Callback (nicht synchron im Effect-Body), um kaskadierende
|
||||
// Renders zu vermeiden (react-hooks/set-state-in-effect).
|
||||
useEffect(() => {
|
||||
const q = query.trim();
|
||||
const mySeq = ++seq.current;
|
||||
const handle = setTimeout(() => {
|
||||
if (q.length < 2) {
|
||||
if (mySeq === seq.current) {
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
startSearch(async () => {
|
||||
const rows = await searchAssets(q);
|
||||
if (mySeq === seq.current) {
|
||||
setResults(rows);
|
||||
setOpen(true);
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
return () => clearTimeout(handle);
|
||||
}, [query]);
|
||||
|
||||
const normQuery = normalizeAssetName(query);
|
||||
const exactHit = results.find((r) => r.normalizedName === normQuery && normQuery.length > 0);
|
||||
// Nah-Duplikate (weiche Warnung): ähnlich, aber nicht exakt gleich.
|
||||
const fuzzyHits = results
|
||||
.filter((r) => r.normalizedName !== normQuery)
|
||||
.map((r) => ({ r, sim: nameSimilarity(query, r.name) }))
|
||||
.filter((x) => x.sim >= FUZZY_SIMILARITY_THRESHOLD)
|
||||
.sort((a, b) => b.sim - a.sim)
|
||||
.slice(0, 3);
|
||||
|
||||
function link(assetId: string) {
|
||||
startSave(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("assetId", assetId);
|
||||
await linkPrimaryInformation(processId, fd);
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function createNew() {
|
||||
if (!normQuery) return;
|
||||
startSave(async () => {
|
||||
const fd = new FormData();
|
||||
fd.set("name", query.trim());
|
||||
fd.set("type", type);
|
||||
fd.set("label", label);
|
||||
await addPrimaryInformation(processId, fd);
|
||||
reset();
|
||||
});
|
||||
}
|
||||
|
||||
function reset() {
|
||||
setQuery("");
|
||||
setResults([]);
|
||||
setOpen(false);
|
||||
setLabel("NONE");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative min-w-[220px] flex-1">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onFocus={() => results.length && setOpen(true)}
|
||||
placeholder="Informationswert erfassen (z. B. Kundendaten) …"
|
||||
className="pl-7"
|
||||
aria-label="Informationswert suchen oder erfassen"
|
||||
/>
|
||||
</div>
|
||||
{!hideType && (
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as "INFORMATION" | "DATA")}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||
aria-label="Werttyp"
|
||||
>
|
||||
<option value="INFORMATION">Information</option>
|
||||
<option value="DATA">Daten</option>
|
||||
</select>
|
||||
)}
|
||||
{!hideLabel && (
|
||||
<select
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-sm"
|
||||
aria-label="Klassifizierungslabel"
|
||||
>
|
||||
{INFO_LABELS.map((l) => (
|
||||
<option key={l.value} value={l.value}>
|
||||
{l.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={createNew}
|
||||
disabled={saving || !normQuery || !!exactHit}
|
||||
>
|
||||
<Plus className="size-4" /> Neu anlegen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Katalog-Vorschläge (M2): suggestedInfoLabels des Prozesses als Ein-Klick-Default. */}
|
||||
{!hideLabel && suggestedLabels.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<Sparkles className="size-3.5" /> Katalog-Vorschlag:
|
||||
</span>
|
||||
{suggestedLabels.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
onClick={() => setLabel(l)}
|
||||
className={`rounded-full border px-2 py-0.5 hover:bg-muted ${
|
||||
label === l ? "border-[var(--brand)] bg-muted font-medium" : ""
|
||||
}`}
|
||||
aria-pressed={label === l}
|
||||
>
|
||||
{infoLabelLabel(l)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weiche Fuzzy-Warnung (§2.1): Nah-Duplikate vor dem Anlegen. */}
|
||||
{!exactHit && fuzzyHits.length > 0 && normQuery.length >= 2 && (
|
||||
<div className="mt-2 rounded-lg border border-[var(--band-brd,var(--warn))] bg-[var(--band,transparent)] p-2 text-[12px]">
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<AlertTriangle className="size-3.5 text-[var(--warn)]" /> Ähnlicher Wert vorhanden — meintest du:
|
||||
</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{fuzzyHits.map(({ r, sim }) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => link(r.id)}
|
||||
disabled={saving}
|
||||
className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 hover:bg-muted"
|
||||
>
|
||||
<Link2 className="size-3" /> {r.name}{" "}
|
||||
<span className="text-muted-foreground">({Math.round(sim * 100)}%)</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Autocomplete-Liste */}
|
||||
{open && results.length > 0 && (
|
||||
<ul className="shadow-card absolute z-20 mt-1 max-h-72 w-full overflow-auto rounded-xl border bg-card p-1 text-sm">
|
||||
{results.map((r) => {
|
||||
const isExact = r.normalizedName === normQuery && normQuery.length > 0;
|
||||
return (
|
||||
<li key={r.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => link(r.id)}
|
||||
disabled={saving}
|
||||
className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left hover:bg-[var(--surface-soft)]"
|
||||
>
|
||||
<Link2 className="mt-0.5 size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.name}</span>
|
||||
<span className="ml-1.5 text-[11px] text-muted-foreground">{r.type}</span>
|
||||
{r.processes.length > 0 && (
|
||||
<span className="mt-0.5 block text-[11px] text-muted-foreground">
|
||||
bereits erfasst in: {r.processes.join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isExact ? <Pill tone="ok">verknüpfen</Pill> : <Pill tone="info">vorhanden</Pill>}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{searching && query.trim().length >= 2 && (
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">suche …</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { Database } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getProcessCatalogSuggestions } from "@/server/actions/processes";
|
||||
import { InformationCombobox } from "./information-combobox";
|
||||
|
||||
/**
|
||||
* M2 Schritt „Information" (Ebene 2). Kernprinzip: Ein Informationswert IST ein
|
||||
* primäres Asset (type INFORMATION/DATA) — kein Extra-Objekt. Je Prozess werden hier
|
||||
* die primären Informations-Assets erfasst, mit Dedup + Autovervollständigung (§2.1,
|
||||
* Combobox). Träger-Assets folgen im nächsten Schritt via ProcessAsset(SECONDARY).
|
||||
*/
|
||||
export async function InformationStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const processes = await db.process.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
processAssets: {
|
||||
where: { role: "PRIMARY", asset: { type: { in: ["INFORMATION", "DATA"] } } },
|
||||
select: { asset: { select: { id: true, name: true, type: true, label: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const totalPrimary = await db.asset.count({ where: { type: { in: ["INFORMATION", "DATA"] } } });
|
||||
const withoutProcess = processes.filter((p) => p.processAssets.length === 0).length;
|
||||
|
||||
// M2: Katalog-Vorschläge (suggestedInfoLabels) je übernommenem Prozess als Defaults.
|
||||
const suggestions = await getProcessCatalogSuggestions();
|
||||
const labelsByProcess = new Map(
|
||||
suggestions.map((s) => [s.processId, s.suggestedInfoLabels.filter((l) => l !== "NONE")]),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Informationswerte je Prozess</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<Database className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Ein Informationswert ist ein <b>primäres Asset</b> (Information/Daten) — kein Extra-Objekt.
|
||||
Erfassen mit Dedup: gleiche Werte (auch bei anderer Schreibweise) werden verknüpft statt
|
||||
doppelt angelegt. Ähnliche Werte werden vor dem Anlegen gewarnt.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: "Prozesse", value: processes.length },
|
||||
{ label: "primäre Informationswerte", value: totalPrimary },
|
||||
{ label: "Prozesse ohne Informationswert", value: withoutProcess },
|
||||
].map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{processes.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">
|
||||
Noch keine Prozesse. Lege im vorigen Schritt „Prozesse“ welche an (Katalog oder eigene),
|
||||
dann erfasse hier die zugehörigen Informationswerte.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{processes.map((p) => (
|
||||
<div key={p.id} className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">{p.name}</span>
|
||||
<Pill tone="mut">{p.category}</Pill>
|
||||
</div>
|
||||
{p.processAssets.length > 0 ? (
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{p.processAssets.map((pa) => (
|
||||
<li
|
||||
key={pa.asset.id}
|
||||
className="inline-flex items-center gap-1 rounded-full border bg-[var(--surface-soft)] px-2 py-0.5 text-[12px]"
|
||||
>
|
||||
{pa.asset.name}
|
||||
<span className="text-[10.5px] text-muted-foreground">{pa.asset.type}</span>
|
||||
{pa.asset.label !== "NONE" && <Pill tone="warn">{pa.asset.label}</Pill>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-2 text-[12px] text-muted-foreground">
|
||||
Noch kein primärer Informationswert erfasst.
|
||||
</p>
|
||||
)}
|
||||
{canUse && (
|
||||
<div className="mt-3">
|
||||
<InformationCombobox
|
||||
processId={p.id}
|
||||
suggestedLabels={labelsByProcess.get(p.id) ?? []}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { StepProps } from "@/lib/onboarding/registry";
|
||||
|
||||
/**
|
||||
* Platzhalter-Schritt (Story A1-1). Die echten Schritt-Komponenten werden über die
|
||||
* Step-Registry eingeklinkt: `scoping` (A2), `context`/`policies` (Dev B), weitere Schritte
|
||||
* in Folge-Stories. Bis dahin rendert die Shell diesen Platzhalter, damit Navigation,
|
||||
* State-Machine und Gate bereits vollständig demonstrierbar sind.
|
||||
*/
|
||||
export async function PlaceholderStep({ stepKey }: StepProps) {
|
||||
const t = await getTranslations("onboarding");
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed bg-[var(--surface-soft)] p-6 text-sm text-muted-foreground">
|
||||
<p className="font-heading text-[15px] font-semibold text-foreground">{t(`stepTitle.${stepKey}`)}</p>
|
||||
<p className="mt-1">{t("placeholderNote")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, FileCheck2 } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { PolicyDomainView } from "@/components/policy-domain-view";
|
||||
import { createPolicyGapTasks } from "@/server/actions/onboarding-steps";
|
||||
|
||||
/**
|
||||
* Richtlinien-/Leitlinien-Schritt (Ebene 1 „Fundament", M1). Setzt auf dem bestehenden
|
||||
* Richtlinienmodul auf (Import/Coverage/Freigabe, Stories B4–B6) — keine Doppel-
|
||||
* Datenhaltung. Die Informationssicherheits-Leitlinie & das Management-Commitment
|
||||
* werden als Fundament-Artefakt gespiegelt (kein Cockpit-Task); zusätzlich Übernahme-/
|
||||
* Freigabestand des Pakets und Aufgaben für Lücken.
|
||||
*/
|
||||
export async function PoliciesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
// Fachbereichs-Bearbeitung (Zuordnung/Editieren/Zur-Prüfung) nur mit policy:write —
|
||||
// sonst wird die Ansicht rein lesend gerendert.
|
||||
const canWritePolicies = hasPermission(session, "policy:write");
|
||||
|
||||
const [leitlinie, state, total, freigegeben, entwurf, openApprovals, requirements] = await Promise.all([
|
||||
db.policyDocument.findFirst({ where: { type: "LEITLINIE", archivedAt: null }, select: { code: true, title: true, status: true, version: true, approvedAt: true } }),
|
||||
db.policyPackageState.findFirst({ select: { importedVersion: true } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null, status: "FREIGEGEBEN" } }),
|
||||
db.policyDocument.count({ where: { archivedAt: null, status: "ENTWURF" } }),
|
||||
db.task.count({ where: { type: "policy_approval", status: "OPEN" } }),
|
||||
db.policyRequirement.count({ where: { archivedAt: null } }),
|
||||
]);
|
||||
|
||||
const leitlinieFreigegeben = leitlinie?.status === "FREIGEGEBEN";
|
||||
|
||||
const kpis = [
|
||||
{ label: "Dokumente", value: total },
|
||||
{ label: "freigegeben", value: freigegeben },
|
||||
{ label: "abgedeckte Anforderungen", value: requirements },
|
||||
];
|
||||
const gaps = [
|
||||
!state?.importedVersion ? { label: "Vorlagenpaket noch nicht übernommen (Paket-Updates)", tone: "warn" as const } : null,
|
||||
entwurf > 0 ? { label: `${entwurf} Richtlinie(n) im Entwurf`, tone: "warn" as const } : null,
|
||||
openApprovals > 0 ? { label: `${openApprovals} offene Freigabe(n)`, tone: "info" as const } : null,
|
||||
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Leitlinie & Management-Commitment — Fundament-Artefakt (kein Cockpit-Task) */}
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold"><FileCheck2 className="size-4" /> Informationssicherheits-Leitlinie & Management-Commitment</span>
|
||||
{leitlinie ? (
|
||||
<Pill tone={leitlinieFreigegeben ? "ok" : "warn"}>{leitlinieFreigegeben ? "freigegeben" : "in Arbeit"}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">nicht vorhanden</Pill>
|
||||
)}
|
||||
</div>
|
||||
{leitlinie ? (
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
<b>{leitlinie.code} · {leitlinie.title}</b> (v{leitlinie.version})
|
||||
{leitlinieFreigegeben
|
||||
? " — von der Leitung freigegeben; dokumentiert das Management-Commitment zum ISMS."
|
||||
: " — noch nicht freigegeben. Die Freigabe durch die oberste Leitung ist das Fundament (Verpflichtungserklärung)."}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
Es ist noch keine Leitlinie (L00) angelegt. Sie ist das Fundament-Artefakt der Ebene 1 —
|
||||
die oberste Leitung verpflichtet sich darin zum ISMS. Über das Richtlinien-Modul anlegen/übernehmen.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies${leitlinie?.code ? `/${leitlinie.code}` : ""}`} />}>
|
||||
<BookOpen className="size-4" /> Leitlinie öffnen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Richtlinien & Verfahren</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies/updates" />}>Paket-Updates</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/policies?view=domains" />}><BookOpen className="size-4" /> Zu den Richtlinien</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Übernommene Paketversion: {state?.importedVersion ?? "—"}. Der Wizard nutzt das bestehende
|
||||
Richtlinienmodul (Import/Upload, Coverage, Vier-Augen-Freigabe) — hier direkt nach Fachbereich.
|
||||
</p>
|
||||
|
||||
{/* Richtlinien nach Fachbereich — Zuordnung, Bearbeiten, „Zur Prüfung geben" direkt im Schritt.
|
||||
Ohne policy:write rein lesend (geteilte Komponente, identisch zu /policies?view=domains). */}
|
||||
<div className="mt-4 border-t pt-4">
|
||||
<p className="font-heading text-[13px] font-semibold">Richtlinien nach Fachbereich</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{canWritePolicies
|
||||
? "Fachbereich zuordnen, bearbeiten und zur Prüfung geben — aus dem primären Control abgeleitet, manuell überschreibbar."
|
||||
: "Übersicht nach Fachbereich (Lesezugriff). Zum Bearbeiten fehlt die Berechtigung „policy:write“."}
|
||||
</p>
|
||||
<PolicyDomainView db={db} currentUserId={session.user.id} canWrite={canWritePolicies} compact returnTo="/onboarding?step=policy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Vollständigkeit</span>
|
||||
{canUse && (
|
||||
<form action={createPolicyGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Paket übernommen, keine Entwürfe/offenen Freigaben — Richtlinienstand ist assessment-tauglich.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
|
||||
<span>{g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronRight, Plus, Trash2, Workflow } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CriticalityPill, OwnerChip, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { adoptCatalogProcess, deleteProcess, toggleProcessScope } from "@/server/actions/processes";
|
||||
import { BIA_TONE } from "@/components/bia-popup";
|
||||
|
||||
const BASE = "/onboarding?step=processes";
|
||||
const biaHref = (id: string) => `${BASE}&bia=${id}&biaStep=1`;
|
||||
|
||||
// Bahnen des Prozesshauses (VDA/TISAX-Ordnung: Management oben, Kern in der Mitte,
|
||||
// Unterstützung unten).
|
||||
const LANES = ["MANAGEMENT", "CORE", "SUPPORT"] as const;
|
||||
|
||||
/** Linker Rahmen der Kachel nach biaStatus (Farbkennzeichnung offen/teilweise/komplett). */
|
||||
const BIA_BORDER: Record<string, string> = {
|
||||
offen: "border-l-[rgba(139,147,173,0.55)]",
|
||||
teilweise: "border-l-[var(--warn)]",
|
||||
komplett: "border-l-[var(--ok)]",
|
||||
};
|
||||
|
||||
/**
|
||||
* TISAX v3A — Prozesshaus als Onboarding-Schritt. Bahnen Management / Kern /
|
||||
* Unterstützung; je Prozess eine Kachel mit Aktivierungs-Schalter (inScope), Owner +
|
||||
* Stellvertreter (bzw. „unbesetzt"), Haupt-/Teilprozess-Tiefe (parentId, aufklappbar)
|
||||
* und Farbkennzeichnung nach biaStatus. Klick auf die Kachel öffnet das geführte
|
||||
* BIA-Popup (Informationswert → Träger → Schutzbedarf → Risiken → Abschluss).
|
||||
*/
|
||||
export async function ProcessesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const t = await getTranslations("processHouse");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
const canUse = hasPermission(session, "onboarding:use") && hasPermission(session, "bia:write");
|
||||
|
||||
const [processes, users, catalog] = await Promise.all([
|
||||
db.process.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
category: true,
|
||||
catalogCode: true,
|
||||
inScope: true,
|
||||
biaStatus: true,
|
||||
ownerId: true,
|
||||
deputyOwnerId: true,
|
||||
parentId: true,
|
||||
owner: { select: { name: true } },
|
||||
bia: { select: { criticality: true } },
|
||||
processAssets: {
|
||||
select: { role: true, asset: { select: { type: true, confidentiality: true, integrity: true, availability: true } } },
|
||||
},
|
||||
_count: { select: { risks: true } },
|
||||
},
|
||||
}),
|
||||
db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }),
|
||||
db.processCatalogEntry.findMany({ orderBy: { orderIdx: "asc" } }),
|
||||
]);
|
||||
|
||||
type Node = (typeof processes)[number];
|
||||
const usersById = new Map(users.map((u) => [u.id, u.name]));
|
||||
const childrenByParent = new Map<string, Node[]>();
|
||||
for (const p of processes) {
|
||||
if (p.parentId) {
|
||||
const arr = childrenByParent.get(p.parentId) ?? [];
|
||||
arr.push(p);
|
||||
childrenByParent.set(p.parentId, arr);
|
||||
}
|
||||
}
|
||||
const topLevel = processes.filter((p) => !p.parentId);
|
||||
|
||||
// Katalog-Übernahme: bereits vorhandene Codes/Namen markieren.
|
||||
const takenCodes = new Set(processes.map((p) => p.name));
|
||||
const takenCatalog = new Set(processes.map((p) => p.catalogCode).filter((c): c is string => !!c));
|
||||
|
||||
// Katalog-Prozess-Tiefe: nur Hauptprozesse listen, Teilprozesse (parentCode) je
|
||||
// Hauptprozess zählen — beim „Übernehmen" werden sie automatisch mit angelegt.
|
||||
const catalogTop = catalog.filter((c) => !c.parentCode);
|
||||
const catalogChildCount = new Map<string, number>();
|
||||
for (const c of catalog) {
|
||||
if (c.parentCode) catalogChildCount.set(c.parentCode, (catalogChildCount.get(c.parentCode) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const inScopeCount = processes.filter((p) => p.inScope).length;
|
||||
const doneCount = processes.filter((p) => p.biaStatus === "komplett").length;
|
||||
|
||||
function progressDots(p: Node) {
|
||||
const primaries = p.processAssets.filter(
|
||||
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
|
||||
);
|
||||
const secondaries = p.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
const dots: { label: string; ok: boolean }[] = [
|
||||
{ label: t("dotInfo"), ok: primaries.length > 0 },
|
||||
{ label: t("dotCarrier"), ok: secondaries.length > 0 },
|
||||
{ label: t("dotCia"), ok: primaries.some((pa) => pa.asset.confidentiality > 1 || pa.asset.integrity > 1 || pa.asset.availability > 1) },
|
||||
{ label: t("dotRisk"), ok: p._count.risks > 0 },
|
||||
];
|
||||
return dots;
|
||||
}
|
||||
|
||||
function tile(p: Node, depth: number) {
|
||||
const kids = childrenByParent.get(p.id) ?? [];
|
||||
const deputyName = p.deputyOwnerId ? usersById.get(p.deputyOwnerId) : null;
|
||||
return (
|
||||
<div key={p.id} className={depth > 0 ? "ml-4 border-l pl-3" : ""}>
|
||||
<div
|
||||
className={`rounded-xl border border-l-[3px] bg-card p-3 ${BIA_BORDER[p.biaStatus] ?? BIA_BORDER.offen} ${
|
||||
p.inScope ? "" : "opacity-60"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link href={biaHref(p.id)} className="flex-1 font-heading text-[13.5px] font-semibold hover:underline">
|
||||
{p.name}
|
||||
</Link>
|
||||
<Pill tone={BIA_TONE[p.biaStatus] ?? "mut"}>{t(`status.${p.biaStatus}`)}</Pill>
|
||||
{p.bia && <CriticalityPill level={p.bia.criticality} label={String(p.bia.criticality)} />}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]">
|
||||
<OwnerChip name={p.owner?.name} noOwnerLabel={t("unassigned")} />
|
||||
<span className="text-muted-foreground">
|
||||
{t("deputy")}: {deputyName ?? <span className="italic">{t("unassigned")}</span>}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
||||
{progressDots(p).map((d) => (
|
||||
<Pill key={d.label} tone={d.ok ? "ok" : "mut"}>{d.label}</Pill>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" nativeButton={false} render={<Link href={biaHref(p.id)} />}>
|
||||
{t("openBia")} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
{canUse && (
|
||||
<form action={toggleProcessScope.bind(null, p.id)}>
|
||||
<input type="hidden" name="inScope" value={p.inScope ? "" : "on"} />
|
||||
<Button type="submit" size="sm" variant="outline">
|
||||
{p.inScope ? t("deactivate") : t("activate")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" nativeButton={false} render={<Link href={`${BASE}&detail=${p.id}`} />}>
|
||||
{t("details")}
|
||||
</Button>
|
||||
{canUse && (
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-7 cursor-pointer list-none items-center gap-1 rounded-[min(var(--radius-md),12px)] border border-input px-2.5 text-[0.8rem] font-semibold text-destructive select-none hover:bg-destructive/10 [&::-webkit-details-marker]:hidden">
|
||||
<Trash2 className="size-3.5" /> {t("delete")}
|
||||
</summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-72 rounded-xl border bg-card p-3 text-[12px]">
|
||||
<p className="mb-2.5 text-muted-foreground">{t("deleteConfirm")}</p>
|
||||
<form action={deleteProcess.bind(null, p.id)}>
|
||||
<input type="hidden" name="returnTo" value="house" />
|
||||
<Button type="submit" size="sm" variant="destructive">
|
||||
<Trash2 className="size-3.5" /> {t("deleteConfirmBtn")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{kids.length > 0 && (
|
||||
<details className="mt-2" open>
|
||||
<summary className="cursor-pointer text-[11.5px] font-semibold text-muted-foreground">
|
||||
{t("subProcesses", { count: kids.length })}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2">{kids.map((k) => tile(k, depth + 1))}</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Kopf + Kennzahlen + Anlegen */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 font-heading text-sm font-semibold">
|
||||
<Workflow className="size-4" /> {t("title")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
{canUse && (
|
||||
<Button size="sm" nativeButton={false} render={<Link href={`${BASE}&new=1`} />}>
|
||||
<Plus className="size-4" /> {t("newProcess")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/processes" />}>
|
||||
{t("toModule")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("intro")}</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
{ label: t("kpiTotal"), value: processes.length },
|
||||
{ label: t("kpiInScope"), value: inScopeCount },
|
||||
{ label: t("kpiDone"), value: doneCount },
|
||||
].map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Legende Farbkennzeichnung */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="font-medium">{t("legend")}:</span>
|
||||
{(["offen", "teilweise", "komplett"] as const).map((s) => (
|
||||
<span key={s} className="inline-flex items-center gap-1.5">
|
||||
<i className={`inline-block h-3 w-3 rounded-sm border-l-[3px] ${BIA_BORDER[s]} bg-card`} />
|
||||
{t(`status.${s}`)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bahnen */}
|
||||
{processes.length === 0 ? (
|
||||
<div className="rounded-xl border bg-card p-4 text-[12.5px] text-muted-foreground">{t("empty")}</div>
|
||||
) : (
|
||||
LANES.map((lane) => {
|
||||
const laneTop = topLevel.filter((p) => p.category === lane);
|
||||
return (
|
||||
<div key={lane} className="rounded-xl border bg-card p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="font-heading text-sm font-semibold">{tCat(lane)}</span>
|
||||
<Tag>{laneTop.length}</Tag>
|
||||
</div>
|
||||
{laneTop.length === 0 ? (
|
||||
<p className="text-[12px] text-muted-foreground">{t("laneEmpty")}</p>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">{laneTop.map((p) => tile(p, 0))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Aus Standard-Katalog übernehmen */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">{t("catalogTitle")}</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("catalogHint")}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{catalogTop.map((c) => {
|
||||
const taken = takenCatalog.has(c.code) || takenCodes.has(c.name);
|
||||
const childCount = catalogChildCount.get(c.code) ?? 0;
|
||||
return (
|
||||
<li key={c.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone="mut">{tCat(c.category)}</Pill>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{c.name}</span>
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{c.code}</span>
|
||||
{childCount > 0 && (
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">
|
||||
· {t("inclSub", { count: childCount })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{taken ? (
|
||||
<Pill tone="ok">{t("adopted")}</Pill>
|
||||
) : canUse ? (
|
||||
<form action={adoptCatalogProcess}>
|
||||
<input type="hidden" name="code" value={c.code} />
|
||||
<Button type="submit" size="sm" variant="outline">{t("adopt")}</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone="info">{tc("none")}</Pill>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill, CiaBadge } from "@/components/mockup-ui";
|
||||
|
||||
/**
|
||||
* M2 Schritt „Schutzbedarf" (Ebene 2). Der Schutzbedarf C/I/A liegt am Asset.
|
||||
* Primäre Informations-Assets tragen den echten Wert; sekundäre Träger erben per
|
||||
* MAXIMUM der von ihnen (im selben Prozess) getragenen primären Werte
|
||||
* (Kumulation/Verteilung bleiben fachliche Entscheidung im Asset-Modul).
|
||||
*
|
||||
* Dieser Schritt analysiert (read-only) den Bestand: unbewertete primäre Werte und
|
||||
* Träger, deren C/I/A unter dem geerbten Maximum liegt. Pflege erfolgt im Asset-Modul.
|
||||
*/
|
||||
export async function ProtectionStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
|
||||
const processes = await db.process.findMany({
|
||||
select: {
|
||||
processAssets: {
|
||||
select: {
|
||||
role: true,
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Primäre Informations-Werte ohne echte Bewertung (alles auf 1) — Bewertung nachholen.
|
||||
const unratedPrimary = new Map<string, string>();
|
||||
// Träger, deren C/I/A unter dem geerbten Maximum liegt (Vererbung verletzt).
|
||||
const inheritanceGaps: { name: string; have: [number, number, number]; need: [number, number, number] }[] = [];
|
||||
|
||||
for (const p of processes) {
|
||||
const primaries = p.processAssets.filter(
|
||||
(pa) => pa.role === "PRIMARY" && (pa.asset.type === "INFORMATION" || pa.asset.type === "DATA"),
|
||||
);
|
||||
const maxC = Math.max(1, ...primaries.map((pa) => pa.asset.confidentiality));
|
||||
const maxI = Math.max(1, ...primaries.map((pa) => pa.asset.integrity));
|
||||
const maxA = Math.max(1, ...primaries.map((pa) => pa.asset.availability));
|
||||
|
||||
for (const pa of primaries) {
|
||||
const a = pa.asset;
|
||||
if (a.confidentiality === 1 && a.integrity === 1 && a.availability === 1) {
|
||||
unratedPrimary.set(a.id, a.name);
|
||||
}
|
||||
}
|
||||
if (primaries.length === 0) continue;
|
||||
for (const pa of p.processAssets) {
|
||||
if (pa.role !== "SECONDARY") continue;
|
||||
const a = pa.asset;
|
||||
if (a.confidentiality < maxC || a.integrity < maxI || a.availability < maxA) {
|
||||
inheritanceGaps.push({
|
||||
name: a.name,
|
||||
have: [a.confidentiality, a.integrity, a.availability],
|
||||
need: [maxC, maxI, maxA],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Schutzbedarf C/I/A</span>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/assets" />}>
|
||||
<ShieldCheck className="size-4" /> Zum Assetinventar
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Der Schutzbedarf liegt am Asset. Primäre Informationswerte tragen den echten Wert; Träger
|
||||
erben per <b>Maximum</b> der getragenen primären Werte. Pflege im Asset-Modul.
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{unratedPrimary.size}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">primäre Werte ohne Bewertung</p>
|
||||
</div>
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{inheritanceGaps.length}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Träger unter geerbtem Maximum</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{unratedPrimary.size > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Unbewertete primäre Werte</span>
|
||||
<ul className="mt-2 flex flex-wrap gap-1.5">
|
||||
{[...unratedPrimary.values()].map((name) => (
|
||||
<li key={name} className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[12px]">
|
||||
{name} <Pill tone="warn">C/I/A offen</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Vererbung (Maximum-Prinzip)</span>
|
||||
{inheritanceGaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
Alle Träger erfüllen mindestens das geerbte Maximum ihrer primären Werte.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{inheritanceGaps.map((g, i) => (
|
||||
<li key={`${g.name}-${i}`} className="flex flex-wrap items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<span className="flex-1 font-medium">{g.name}</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
ist <CiaBadge c={g.have[0]} i={g.have[1]} a={g.have[2]} />
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
soll (min.) <CiaBadge c={g.need[0]} i={g.need[1]} a={g.need[2]} />
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Link from "next/link";
|
||||
import { ShieldAlert } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { createRiskGapTasks } from "@/server/actions/onboarding-steps";
|
||||
import { getSuggestedRisksForProcesses, adoptSuggestedRisk } from "@/server/actions/risk-catalog";
|
||||
|
||||
/**
|
||||
* Risiken-Schritt (Schritt 6). Setzt auf dem bestehenden Risikomodul auf (Register,
|
||||
* Heatmap, Standard-Risikokatalog C4) — keine Doppel-Datenhaltung. Zeigt Register-/
|
||||
* Katalogstand und offene Behandlungslücken und legt dafür Aufgaben an.
|
||||
*/
|
||||
export async function RisksStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const [total, high, highUntreated, catalogTotal, adopted, accepted] = await Promise.all([
|
||||
db.risk.count(),
|
||||
db.risk.count({ where: { score: { gte: 10 } } }),
|
||||
db.risk.count({ where: { score: { gte: 10 }, treatment: "MITIGATE", riskMeasures: { none: {} } } }),
|
||||
db.riskCatalogEntry.count(),
|
||||
db.risk.count({ where: { catalogCode: { not: null } } }),
|
||||
db.risk.count({ where: { status: "ACCEPTED" } }),
|
||||
]);
|
||||
|
||||
const kpis = [
|
||||
{ label: "Risiken im Register", value: total },
|
||||
{ label: `Katalog übernommen (von ${catalogTotal})`, value: adopted },
|
||||
{ label: "Hoch-Risiken", value: high },
|
||||
];
|
||||
const gaps = [
|
||||
total === 0 ? { label: "Register leer — Standard-Risikokatalog sichten und relevante Risiken übernehmen", tone: "warn" as const } : null,
|
||||
total > 0 && adopted === 0 && catalogTotal > 0 ? { label: "Standard-Risikokatalog (C4) noch nicht genutzt", tone: "info" as const } : null,
|
||||
highUntreated > 0 ? { label: `${highUntreated} Hoch-Risiko/-Risiken ohne verknüpfte Maßnahme`, tone: "warn" as const } : null,
|
||||
].filter(Boolean) as { label: string; tone: "warn" | "info" }[];
|
||||
|
||||
// M2: Standard-Risiken (suggestedRiskCodes) je übernommenem Katalog-Prozess mit
|
||||
// Ein-Klick-Übernahme (adoptSuggestedRisk, ohne den Wizard zu verlassen).
|
||||
const riskSuggestions = await getSuggestedRisksForProcesses();
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Risikoanalyse</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks/catalog" />}>Risikokatalog</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href="/risks" />}><ShieldAlert className="size-4" /> Zum Risikoregister</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
{kpis.map((k) => (
|
||||
<div key={k.label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-heading text-2xl font-bold leading-none">{k.value}</p>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{k.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-[11.5px] text-muted-foreground">
|
||||
Davon {accepted} akzeptiert. Der Wizard nutzt das bestehende Risikomodul (Register,
|
||||
Heatmap, Katalog-Übernahme, Maßnahmenverknüpfung) — Pflege dort.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Behandlungsstand</span>
|
||||
{canUse && (
|
||||
<form action={createRiskGapTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{gaps.length ? ` (${gaps.length})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
{gaps.length === 0 ? (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">Risiken erfasst und Hoch-Risiken mit Maßnahmen hinterlegt — Risikostand ist assessment-tauglich.</p>
|
||||
) : (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{gaps.map((g) => (
|
||||
<li key={g.label} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone={g.tone}>{g.tone === "info" ? "Hinweis" : "Lücke"}</Pill>
|
||||
<span>{g.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{riskSuggestions.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<span className="font-heading text-sm font-semibold">Empfohlene Risiken je Prozess (Katalog)</span>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Standard-Risiken (C4) aus dem Prozess-Katalog. „Übernehmen“ legt das Risiko mit
|
||||
Default-Bewertung im Register an — anschließend dort anpassen.
|
||||
</p>
|
||||
<div className="mt-3 space-y-3">
|
||||
{riskSuggestions.map((s) => (
|
||||
<div key={s.processId}>
|
||||
<p className="text-[12.5px] font-medium">{s.processName}</p>
|
||||
<ul className="mt-1.5 space-y-2">
|
||||
{s.risks.map((r) => (
|
||||
<li key={r.code} className="flex items-center gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<Pill tone="mut">{r.category}</Pill>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium">{r.title}</span>
|
||||
<span className="ml-1.5 text-[10.5px] text-muted-foreground">{r.code}</span>
|
||||
</span>
|
||||
{r.adopted ? (
|
||||
<Pill tone="ok">übernommen</Pill>
|
||||
) : canUse ? (
|
||||
<form action={adoptSuggestedRisk.bind(null, r.code)}>
|
||||
<Button type="submit" size="sm" variant="outline">Übernehmen</Button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone="info">offen</Pill>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Client-seitiges Wizard-Popup im Stil des serverseitigen `Modal` (src/components/modal.tsx),
|
||||
* aber mit lokalem Open-State statt searchParams — die Schritt-Komponenten des Onboardings
|
||||
* erhalten keine searchParams, daher wird das Öffnen/Schließen hier per State gesteuert.
|
||||
* Optik (Overlay, Karte, Header, Footer) bleibt bewusst identisch zum bestehenden Modal.
|
||||
*/
|
||||
export function Popup({
|
||||
triggerLabel,
|
||||
triggerIcon,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "sm",
|
||||
triggerClassName,
|
||||
title,
|
||||
sub,
|
||||
maxWidthClass = "max-w-2xl",
|
||||
children,
|
||||
}: {
|
||||
triggerLabel: ReactNode;
|
||||
triggerIcon?: ReactNode;
|
||||
triggerVariant?: "default" | "outline" | "secondary" | "ghost" | "link";
|
||||
triggerSize?: "default" | "sm" | "xs" | "lg";
|
||||
triggerClassName?: string;
|
||||
title: string;
|
||||
sub?: string;
|
||||
maxWidthClass?: string;
|
||||
/** Erhält eine `close`-Funktion, damit Formulare nach dem Speichern schließen können. */
|
||||
children: (close: () => void) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const close = () => setOpen(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant={triggerVariant}
|
||||
size={triggerSize}
|
||||
className={triggerClassName}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{triggerIcon}
|
||||
{triggerLabel}
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={`shadow-card my-auto w-full ${maxWidthClass} rounded-2xl border bg-card`}>
|
||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{title}</p>
|
||||
{sub && <p className="mt-0.5 text-[12.5px] text-muted-foreground">{sub}</p>}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
title="Schließen"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4.5" />
|
||||
</button>
|
||||
</div>
|
||||
{children(close)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { UserPlus, UserMinus, Mail, CircleSlash, Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import {
|
||||
assignFunction,
|
||||
unassignFunction,
|
||||
markFunctionUnfilled,
|
||||
inviteFunctionHolder,
|
||||
} from "@/server/actions/onboarding-team";
|
||||
import { Popup } from "./popup";
|
||||
|
||||
export interface HolderView {
|
||||
id: string;
|
||||
userName: string | null;
|
||||
userEmail: string | null;
|
||||
invitedEmail: string | null;
|
||||
}
|
||||
|
||||
export interface FunctionCardData {
|
||||
key: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
domain: string | null;
|
||||
multi: boolean;
|
||||
soll: string | null;
|
||||
holders: HolderView[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten einer ISMS-Funktion im Popup — Logik wie beim Risiken-Popup, nutzt die
|
||||
* bestehenden M1-Server-Actions (`assignFunction`, `unassignFunction`,
|
||||
* `markFunctionUnfilled`, `inviteFunctionHolder`). Auslöser ist der „Bearbeiten“-Button
|
||||
* auf der Funktionskarte (siehe step.tsx).
|
||||
*/
|
||||
export function FunctionEditDialog({
|
||||
fn,
|
||||
users,
|
||||
canUse,
|
||||
canInvite,
|
||||
}: {
|
||||
fn: FunctionCardData;
|
||||
users: { id: string; name: string; email: string }[];
|
||||
canUse: boolean;
|
||||
canInvite: boolean;
|
||||
}) {
|
||||
const filled = fn.holders.length > 0;
|
||||
const canAssignMore = canUse && (fn.multi || !filled);
|
||||
|
||||
return (
|
||||
<Popup
|
||||
triggerLabel={canUse ? "Bearbeiten" : "Ansehen"}
|
||||
triggerIcon={<Pencil className="size-3.5" />}
|
||||
triggerVariant="outline"
|
||||
triggerSize="sm"
|
||||
title={fn.label}
|
||||
sub={fn.domain ? `Bereich: ${fn.domain}` : undefined}
|
||||
>
|
||||
{() => (
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<p className="text-[12.5px] leading-relaxed">{fn.desc}</p>
|
||||
{fn.soll && (
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">
|
||||
Soll (zentral): <b>{fn.soll}</b>
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
{filled ? (
|
||||
<Pill tone="ok">besetzt{fn.multi ? ` (${fn.holders.length})` : ""}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">unbesetzt</Pill>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Aktuelle Halter */}
|
||||
<div>
|
||||
<p className="text-[12.5px] font-medium">Aktuelle Besetzung</p>
|
||||
{filled ? (
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{fn.holders.map((h) => (
|
||||
<li
|
||||
key={h.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border bg-background px-3 py-2 text-[12.5px]"
|
||||
>
|
||||
<span>
|
||||
<b>{h.userName ?? h.invitedEmail ?? "—"}</b>
|
||||
{h.userEmail && (
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">{h.userEmail}</span>
|
||||
)}
|
||||
{h.invitedEmail && !h.userName && (
|
||||
<span className="ml-1 text-[11px] text-[var(--info)]">eingeladen</span>
|
||||
)}
|
||||
</span>
|
||||
{canUse && (
|
||||
<form action={unassignFunction.bind(null, h.id)}>
|
||||
<Button type="submit" size="sm" variant="ghost" className="h-7 px-2 text-[11.5px]">
|
||||
<UserMinus className="size-3.5" /> entfernen
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[12px] text-muted-foreground">Noch niemand zugewiesen.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zuweisen / Einladen / unbesetzt lassen */}
|
||||
{canAssignMore && (
|
||||
<div className="space-y-3 border-t pt-3">
|
||||
<form action={assignFunction.bind(null, fn.key)} className="flex items-center gap-1.5">
|
||||
<select
|
||||
name="userId"
|
||||
required
|
||||
defaultValue=""
|
||||
className="h-8 flex-1 rounded-md border border-input bg-background px-2 text-[12.5px]"
|
||||
>
|
||||
<option value="" disabled>
|
||||
Account auswählen …
|
||||
</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8">
|
||||
<UserPlus className="size-3.5" /> zuweisen
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<form action={inviteFunctionHolder.bind(null, fn.key)} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
placeholder="E-Mail einladen"
|
||||
className="h-8 flex-1 text-[12.5px]"
|
||||
disabled={!canInvite}
|
||||
/>
|
||||
<Button type="submit" size="sm" variant="outline" className="h-8" disabled={!canInvite}>
|
||||
<Mail className="size-3.5" /> einladen
|
||||
</Button>
|
||||
</form>
|
||||
{!canInvite && (
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!filled && (
|
||||
<form action={markFunctionUnfilled.bind(null, fn.key)}>
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-[11.5px] text-muted-foreground"
|
||||
>
|
||||
<CircleSlash className="size-3.5" /> unbesetzt lassen (Aufgabe anlegen)
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!canUse && (
|
||||
<p className="border-t pt-3 text-[12px] text-muted-foreground">
|
||||
Nur Lesezugriff — Änderungen erfordern das Recht „Onboarding nutzen“.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Popup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import Link from "next/link";
|
||||
import { Settings, Users } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { loadRoleContext, loadFunctionAssignments, listAssignableUsers } from "@/server/roles";
|
||||
import { evaluateFt, FT_SEVERITY_LABEL, FT_SEVERITY_TONE } from "@/lib/ft-rules";
|
||||
import { renderIsbBestellung } from "@/lib/isb-bestellung";
|
||||
import { FUNCTION_DEFS, type FunctionDef } from "@/lib/onboarding/functions";
|
||||
import { createFtTasks } from "@/server/actions/onboarding";
|
||||
import { FunctionEditDialog } from "./role-edit-dialog";
|
||||
|
||||
/**
|
||||
* Team / Funktionszuordnung (Ebene 1 „Fundament", M1). Löst die read-only
|
||||
* Rollen-Anzeige durch eine echte Zuweisung Funktion → User(n) ab: bestehende
|
||||
* Accounts zuweisen, per E-Mail einladen (SEC2) oder eine Funktion bewusst
|
||||
* unbesetzt lassen (→ Task „Funktion besetzen"). Ergänzend die Funktionstrennungs-
|
||||
* Prüfung (FT-01…06) und die ISB-Bestellung als Vorlage.
|
||||
*/
|
||||
export async function RolesStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
const canInvite = hasPermission(session, "user:manage");
|
||||
|
||||
const [ctx, assignments, users] = await Promise.all([
|
||||
loadRoleContext(db),
|
||||
loadFunctionAssignments(db),
|
||||
listAssignableUsers(db),
|
||||
]);
|
||||
|
||||
const findings = evaluateFt(ctx);
|
||||
const openTasks = findings.filter((f) => f.task).length;
|
||||
const date = new Date().toLocaleDateString("de-DE");
|
||||
const bestellung = renderIsbBestellung({ ISB: ctx.roles.ISB, MANAGEMENT: ctx.roles.MANAGEMENT, ORG_NAME: ctx.orgName, DOC_DATE: date });
|
||||
|
||||
const byFunction = new Map<string, typeof assignments>();
|
||||
for (const a of assignments) {
|
||||
const list = byFunction.get(a.functionKey) ?? [];
|
||||
list.push(a);
|
||||
byFunction.set(a.functionKey, list);
|
||||
}
|
||||
|
||||
// Soll-Benennung aus der korrespondierenden Zentralvariable (Vorschlag/Abgleich).
|
||||
const sollOf = (def: FunctionDef): string | null => {
|
||||
if (!def.roleVar) return null;
|
||||
const key = def.roleVar.replace(/^ROLE_/, "") as keyof typeof ctx.roles;
|
||||
const v = (ctx.roles[key] ?? "").trim();
|
||||
return v || null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Team / Funktionszuordnung */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Team & Funktionszuordnung</span>
|
||||
<Link href="/settings" className="inline-flex items-center gap-1 text-[11.5px] font-medium text-[var(--primary)] hover:underline">
|
||||
<Settings className="size-3" /> Soll-Rollen & Zentralvariablen
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">
|
||||
Jede ISMS-Funktion als Karte — Besetzung auf einen Blick. Zum Zuweisen, Einladen oder
|
||||
bewussten Unbesetzt-Lassen (erzeugt eine Aufgabe „Funktion besetzen“) die Karte
|
||||
<b> bearbeiten</b>.
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
{FUNCTION_DEFS.map((def) => {
|
||||
const holders = byFunction.get(def.key) ?? [];
|
||||
const soll = sollOf(def);
|
||||
const filled = holders.length > 0;
|
||||
return (
|
||||
<div
|
||||
key={def.key}
|
||||
className={`flex flex-col rounded-xl border border-l-[3px] bg-[var(--surface-soft)] p-3.5 ${filled ? "border-l-[var(--ok)]" : "border-l-[var(--warn)]"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-[13px] font-semibold">{def.label}</p>
|
||||
{filled ? (
|
||||
<Pill tone="ok">besetzt{def.multi ? ` (${holders.length})` : ""}</Pill>
|
||||
) : (
|
||||
<Pill tone="warn">unbesetzt</Pill>
|
||||
)}
|
||||
</div>
|
||||
{def.domain && (
|
||||
<p className="mt-0.5 text-[11px] text-muted-foreground">Bereich: {def.domain}</p>
|
||||
)}
|
||||
<p className="mt-1 line-clamp-2 text-[11.5px] text-muted-foreground">{def.desc}</p>
|
||||
|
||||
<div className="mt-2 min-h-[1.25rem] text-[12px]">
|
||||
{filled ? (
|
||||
<span className="inline-flex flex-wrap items-center gap-1.5">
|
||||
<Users className="size-3.5 text-muted-foreground" />
|
||||
{holders.map((h) => (
|
||||
<span key={h.id} className="rounded-md bg-background px-1.5 py-0.5">
|
||||
{h.userName ?? h.invitedEmail ?? "—"}
|
||||
{h.invitedEmail && !h.userName && (
|
||||
<span className="ml-1 text-[10.5px] text-[var(--info)]">eingeladen</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{soll ? (
|
||||
<>Soll (zentral): <b>{soll}</b></>
|
||||
) : (
|
||||
"Noch keine Person zugewiesen."
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex justify-end border-t pt-2.5">
|
||||
<FunctionEditDialog
|
||||
fn={{
|
||||
key: def.key,
|
||||
label: def.label,
|
||||
desc: def.desc,
|
||||
domain: def.domain,
|
||||
multi: Boolean(def.multi),
|
||||
soll,
|
||||
holders: holders.map((h) => ({
|
||||
id: h.id,
|
||||
userName: h.userName,
|
||||
userEmail: h.userEmail,
|
||||
invitedEmail: h.invitedEmail,
|
||||
})),
|
||||
}}
|
||||
users={users}
|
||||
canUse={canUse}
|
||||
canInvite={canInvite}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{!canInvite && <p className="mt-2 text-[11px] text-muted-foreground">Einladen neuer Accounts erfordert das Recht „Benutzerverwaltung“.</p>}
|
||||
</div>
|
||||
|
||||
{/* Funktionstrennung FT-01…06 */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Funktionstrennung (FT-01…06)</span>
|
||||
{canUse && (
|
||||
<form action={createFtTasks}>
|
||||
<Button type="submit" size="sm" variant="outline">Prüfen & Aufgaben anlegen{openTasks ? ` (${openTasks})` : ""}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{findings.map((f) => (
|
||||
<li key={f.code} className="flex flex-wrap items-start gap-2 border-b pb-2 text-[12.5px] last:border-0">
|
||||
<span className="font-mono text-[11px] text-muted-foreground">{f.code}</span>
|
||||
<Pill tone={FT_SEVERITY_TONE[f.severity]}>{FT_SEVERITY_LABEL[f.severity]}</Pill>
|
||||
<span className="flex-1">
|
||||
<b>{f.title}</b>
|
||||
{f.control ? <span className="text-muted-foreground"> · Control {f.control}</span> : null}
|
||||
<span className="block text-[11.5px] text-muted-foreground">{f.message}</span>
|
||||
{f.task && <span className="text-[11px] text-[var(--info)]">→ Aufgabe{f.task.reuse ? " (aus Fragebogen)" : ""}</span>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* ISB-Bestellung (inline, aus C7-Baustein) */}
|
||||
<details className="rounded-xl border bg-card p-4">
|
||||
<summary className="cursor-pointer list-none text-[13px] font-semibold [&::-webkit-details-marker]:hidden">ISB-Bestellung anzeigen (Vorlage)</summary>
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">Erzeugt aus den zentralen Variablen — Vorschau/Kopiervorlage (keine Paket-Vorlage).</p>
|
||||
<pre className="mt-2 max-h-80 overflow-auto rounded-lg border bg-[var(--surface-soft)] p-3 text-[11.5px] whitespace-pre-wrap">{bestellung}</pre>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Lock } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { getAssessmentLevel, protectionFlags } from "@/server/assessment-level";
|
||||
import { scopeSummary, type Pruefziel } from "@/lib/scope-filter";
|
||||
import { saveScope } from "@/server/actions/onboarding";
|
||||
|
||||
/**
|
||||
* Scoping-Schritt (Story A2). Zeigt das Assessment-Level read-only (A2-1) und erfasst
|
||||
* den Geltungsbereich (Prüfziele, Standorte, Ausschlüsse) als WizardScope (A2-2). Eine
|
||||
* Live-Vorschau zeigt die daraus resultierenden aktiven Anforderungen (Scope-Filter).
|
||||
*/
|
||||
export async function ScopingStep() {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const canUse = hasPermission(session, "onboarding:use");
|
||||
|
||||
const level = await getAssessmentLevel(db);
|
||||
const veryHigh = level === "AL3";
|
||||
const scopeLabel = veryHigh ? "MUSS · SOLL · HOCH · SEHR HOCH" : "MUSS · SOLL · HOCH";
|
||||
|
||||
const scope = await db.wizardScope.findFirst();
|
||||
const pruefziele = (scope?.pruefziele as Pruefziel[]) ?? ["informationssicherheit"];
|
||||
const hasProto = pruefziele.includes("prototypenschutz");
|
||||
const hasDatenschutz = pruefziele.includes("datenschutz");
|
||||
|
||||
// FLAG_INCLUDE_SHOULD (SOLL-Anforderungen) stammt aus dem Fragebogen (Q-GOV-05, Default an).
|
||||
const should = await db.wizardFact.findUnique({ where: { tenantId_key: { tenantId: session.user.tenantId, key: "Q-GOV-05" } } });
|
||||
const includeShould = should ? should.value === true : true;
|
||||
|
||||
const summary = scopeSummary({ pruefziele, flags: { ...protectionFlags(level), FLAG_INCLUDE_SHOULD: includeShould } });
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Assessment-Level (read-only, A2-1) */}
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold">Assessment-Level</span>
|
||||
<Pill tone={veryHigh ? "risk" : "info"}>{level}</Pill>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] text-muted-foreground"><Lock className="size-3" /> read-only</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">
|
||||
Geprüfte Anforderungsstufen: <b>{scopeLabel}</b>. Zentral im Admin-/Superadmin-Portal
|
||||
gesetzt (einzige Quelle des Schutzbedarfs); im Wizard nicht änderbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scope erfassen */}
|
||||
<form action={saveScope} className="space-y-4">
|
||||
<fieldset className="rounded-xl border bg-card p-4">
|
||||
<legend className="px-1 text-[13px] font-semibold">Prüfziele</legend>
|
||||
<div className="mt-1 space-y-2 text-sm">
|
||||
<label className="flex items-center gap-2 text-muted-foreground">
|
||||
<input type="checkbox" checked disabled /> Informationssicherheit <span className="text-[11.5px]">(immer aktiv)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="pz_prototypenschutz" defaultChecked={hasProto} /> Prototypenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 8.x)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="pz_datenschutz" defaultChecked={hasDatenschutz} /> Datenschutz <span className="text-[11.5px] text-muted-foreground">(Kapitel 9.x)</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="geltungsbereich">Geltungsbereich</label>
|
||||
<Input id="geltungsbereich" name="geltungsbereich" defaultValue={scope?.geltungsbereich ?? ""} placeholder="z. B. gesamte Organisation / definierter Bereich" className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="standorte">Standorte <span className="text-[11.5px] text-muted-foreground">(einer je Zeile)</span></label>
|
||||
<Textarea id="standorte" name="standorte" rows={3} defaultValue={(scope?.standorte ?? []).join("\n")} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[13px] font-medium" htmlFor="ausschluesse">Ausschlüsse</label>
|
||||
<Textarea id="ausschluesse" name="ausschluesse" rows={3} defaultValue={scope?.ausschluesse ?? ""} placeholder="Ausgeschlossene Bereiche/Systeme" className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canUse && <Button type="submit">Scope speichern</Button>}
|
||||
</form>
|
||||
|
||||
{/* Live-Vorschau der aktiven Anforderungen (Scope-Filter) */}
|
||||
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<b>Resultierender Scope</b>
|
||||
<span className="font-heading text-[15px] font-bold text-foreground">{summary.total} Anforderungen</span>
|
||||
</div>
|
||||
<p className="mt-1">
|
||||
über {summary.controls} Controls · MUSS {summary.byType.MUSS} · SOLL {summary.byType.SOLL} · HOCH {summary.byType.HOCH} · SEHR HOCH {summary.byType["SEHR HOCH"]}
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
Abgeleitet aus Prüfzielen, Assessment-Level ({level}) und SOLL-Einbezug
|
||||
({includeShould ? "an" : "aus"}). Ändert sich der Scope, passen sich die nachgelagerten Schritte an.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user