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,129 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Asset, AssetType } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
|
||||
const ASSET_TYPES = ["INFORMATION", "SYSTEM", "APPLICATION", "LOCATION", "SUPPLIER", "IT_SERVICE", "PERSON", "DATA"] as const;
|
||||
const ASSET_STATUS = ["ACTIVE", "PLANNED", "RETIRED"] as const;
|
||||
|
||||
/** Formular für Anlegen/Bearbeiten eines Assets (Server-Action wird übergeben). */
|
||||
export async function AssetForm({
|
||||
action,
|
||||
asset,
|
||||
users,
|
||||
cancelHref,
|
||||
defaultType = "SYSTEM",
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
asset?: Asset;
|
||||
users: { id: string; name: string }[];
|
||||
cancelHref: string;
|
||||
/** Vorbelegter Typ beim Anlegen (z. B. INFORMATION im BIA-Popup Schritt 1). */
|
||||
defaultType?: AssetType;
|
||||
}) {
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const selectClass =
|
||||
"h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<form action={action} className="max-w-2xl space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={asset?.name} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={asset?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="type">{t("type")}</Label>
|
||||
<select id="type" name="type" defaultValue={asset?.type ?? defaultType} className={`${selectClass} mt-1`}>
|
||||
{ASSET_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tType(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">{t("status")}</Label>
|
||||
<select id="status" name="status" defaultValue={asset?.status ?? "ACTIVE"} className={`${selectClass} mt-1`}>
|
||||
{ASSET_STATUS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tStatus(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select id="ownerId" name="ownerId" defaultValue={asset?.ownerId ?? ""} className={`${selectClass} mt-1`}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="location">{t("location")}</Label>
|
||||
<Input id="location" name="location" defaultValue={asset?.location ?? ""} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", t("confidentiality"), asset?.confidentiality],
|
||||
["integrity", t("integrity"), asset?.integrity],
|
||||
["availability", t("availability"), asset?.availability],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating
|
||||
name={name}
|
||||
defaultValue={value ?? 1}
|
||||
low={tLevel("1")}
|
||||
high={tLevel("4")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tags">{t("tags")}</Label>
|
||||
<Input id="tags" name="tags" defaultValue={asset?.tags.join(", ") ?? ""} className="mt-1" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Network, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addAssetRelation,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
removeAssetRelation,
|
||||
updateAsset,
|
||||
} from "@/server/actions/assets";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
|
||||
export type AssetWithRelations = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
owner: { select: { name: true } };
|
||||
relationsFrom: {
|
||||
include: {
|
||||
relatedAsset: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
type: true;
|
||||
confidentiality: true;
|
||||
integrity: true;
|
||||
availability: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||
processAssets: { include: { process: { select: { id: true; name: true } } } };
|
||||
riskAssets: {
|
||||
include: { risk: { select: { id: true; refNo: true; title: true; score: true } } };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const STATUS_TONE = { ACTIVE: "ok", PLANNED: "info", RETIRED: "mut" } as const;
|
||||
|
||||
/** Read-only-Detailansicht als Popup — optisch analog zum Prozess-Detail. */
|
||||
export async function AssetDetailModal({
|
||||
asset,
|
||||
canWrite,
|
||||
}: {
|
||||
asset: AssetWithRelations;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("assetStatus");
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tRisks = await getTranslations("risks");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
const tp = await getTranslations("processes");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={asset.name}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tType(asset.type)}</Tag>
|
||||
<Pill tone={STATUS_TONE[asset.status]}>{tStatus(asset.status)}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref="/assets"
|
||||
closeLabel={tp("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/assets?edit=${asset.id}`} />}
|
||||
>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/assets" />}>
|
||||
{tp("close")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten — violette Karte wie das primäre Asset im Prozess-Popup */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="violet">{t("masterPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("masterNote")}</span>
|
||||
</div>
|
||||
<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">
|
||||
<b>{asset.name}</b>
|
||||
<CiaBadge c={asset.confidentiality} i={asset.integrity} a={asset.availability} labels />
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{t("owner")}: {asset.owner?.name ?? tc("none")} · {t("type")}: {tType(asset.type)}
|
||||
{asset.location ? ` · ${t("location")}: ${asset.location}` : ""}
|
||||
</div>
|
||||
{asset.description && (
|
||||
<p className="mt-2 text-[12.5px] leading-relaxed">{asset.description}</p>
|
||||
)}
|
||||
{asset.tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{asset.tags.map((tag) => (
|
||||
<Tag key={tag}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold">{t("processes")}</p>
|
||||
{asset.processAssets.length === 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{asset.processAssets.map((pa) => (
|
||||
<li key={pa.id} className="flex items-center gap-2">
|
||||
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">
|
||||
{pa.process.name}
|
||||
</Link>
|
||||
<Pill tone={pa.role === "PRIMARY" ? "violet" : "info"}>{tRole(pa.role)}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abhängigkeiten — Tabelle wie die sekundären Assets im Prozess-Popup */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{t("depPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("depNote")}</span>
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
|
||||
{asset.relationsFrom.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="pb-1 text-right font-normal">
|
||||
<CiaLegend />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asset.relationsFrom.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">
|
||||
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">
|
||||
{rel.relatedAsset.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-2">
|
||||
<Tag>{tType(rel.relatedAsset.type)}</Tag>
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge
|
||||
c={rel.relatedAsset.confidentiality}
|
||||
i={rel.relatedAsset.integrity}
|
||||
a={rel.relatedAsset.availability}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("relationReverseHint")}</p>
|
||||
{asset.relationsTo.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
{asset.relationsTo.map((rel) => (
|
||||
<li key={rel.id}>
|
||||
<Link href={`/assets?detail=${rel.asset.id}`} className="hover:underline">
|
||||
{rel.asset.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href="/dependencies"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
|
||||
>
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zugeordnete Risiken — Band wie die BIA-Kennzahlen im Prozess-Popup */}
|
||||
<div className="mx-5 mb-5 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>{t("linkedRisks")}</b>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/risks?new=1&asset=${asset.id}`} />}
|
||||
>
|
||||
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{asset.riskAssets.length === 0 && <p className="mt-1">{t("linkedRisksPlaceholder")}</p>}
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{asset.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">
|
||||
{riskRef(ra.risk.refNo)}
|
||||
</Link>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">
|
||||
{ra.risk.title}
|
||||
</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>
|
||||
{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}
|
||||
</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten als Popup: Stammdaten-Formular + Abhängigkeiten + Löschen. */
|
||||
export async function AssetEditModal({
|
||||
asset,
|
||||
users,
|
||||
otherAssets,
|
||||
}: {
|
||||
asset: AssetWithRelations;
|
||||
users: { id: string; name: string }[];
|
||||
otherAssets: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("assets");
|
||||
const tc = await getTranslations("common");
|
||||
const tp = await getTranslations("processes");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={asset.name}
|
||||
closeHref={`/assets?detail=${asset.id}`}
|
||||
closeLabel={tp("close")}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-[1fr_16rem]">
|
||||
<AssetForm
|
||||
action={updateAsset.bind(null, asset.id)}
|
||||
asset={asset}
|
||||
users={users}
|
||||
cancelHref={`/assets?detail=${asset.id}`}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-semibold">{t("relations")}</p>
|
||||
<p className="mt-1 text-[12.5px] text-muted-foreground">{t("relationHint")}</p>
|
||||
<ul className="mt-2 space-y-1.5 text-sm">
|
||||
{asset.relationsFrom.map((rel) => (
|
||||
<li key={rel.id} className="flex items-center gap-2">
|
||||
{rel.relatedAsset.name}
|
||||
<form action={removeAssetRelation.bind(null, asset.id, rel.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{otherAssets.length > 0 && (
|
||||
<form action={addAssetRelation.bind(null, asset.id)} className="mt-3 space-y-2">
|
||||
<select
|
||||
name="relatedAssetId"
|
||||
required
|
||||
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm"
|
||||
>
|
||||
{otherAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("addRelation")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t pt-4">
|
||||
<form action={deleteAsset.bind(null, asset.id)}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anlegen als Popup. */
|
||||
export async function AssetCreateModal({
|
||||
users,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("assets");
|
||||
const tp = await getTranslations("processes");
|
||||
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/assets" closeLabel={tp("close")}>
|
||||
<div className="p-5">
|
||||
<AssetForm action={createAsset} users={users} cancelHref="/assets" />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
|
||||
/**
|
||||
* Wiederverwendbarer Audit-Trail (Aktivitätsprotokoll) als server-gerendertes
|
||||
* Popup — gleiches Muster wie die übrigen Popups (searchParam öffnet/schließt,
|
||||
* kein Client-State). Wird an zwei Stellen eingebunden:
|
||||
* - Mandanten-Einstellungen (/settings?audit=1): eigener Mandant, RLS-gefiltert.
|
||||
* - Plattform-Konsole je Mandant (/admin/[id]?audit=1): Superadmin liest den
|
||||
* Trail des jeweiligen Mandanten cross-tenant über den Owner-Client.
|
||||
* Die Zeilen werden bereits gefiltert/aufbereitet übergeben — die Komponente
|
||||
* rendert nur; das hält den Datenpfad (Mandant vs. Plattform) in der Seite.
|
||||
*/
|
||||
|
||||
export type AuditRow = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
actorId: string | null;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId: string | null;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
create: "Erstellt",
|
||||
update: "Geändert",
|
||||
delete: "Gelöscht",
|
||||
login: "Anmeldung",
|
||||
logout: "Abmeldung",
|
||||
denied: "Abgelehnt",
|
||||
export: "Export",
|
||||
import: "Import",
|
||||
approve: "Freigegeben",
|
||||
reject: "Zurückgewiesen",
|
||||
};
|
||||
|
||||
const ACTION_TONE: Record<string, "ok" | "warn" | "mut" | "info"> = {
|
||||
create: "ok",
|
||||
update: "info",
|
||||
approve: "ok",
|
||||
delete: "warn",
|
||||
denied: "warn",
|
||||
reject: "warn",
|
||||
login: "mut",
|
||||
logout: "mut",
|
||||
};
|
||||
|
||||
const ENTITY_LABEL: Record<string, string> = {
|
||||
asset: "Asset",
|
||||
process: "Prozess",
|
||||
risk: "Risiko",
|
||||
measure: "Maßnahme",
|
||||
supplier: "Lieferant",
|
||||
software: "Software",
|
||||
project: "Projekt",
|
||||
service: "IT-Dienst",
|
||||
policy: "Richtlinie",
|
||||
task: "Aufgabe",
|
||||
user: "Benutzer",
|
||||
role: "Rolle",
|
||||
tenant: "Mandant",
|
||||
module: "Modul",
|
||||
session: "Sitzung",
|
||||
bia: "BIA",
|
||||
platformAdmin: "Plattform-Admin",
|
||||
};
|
||||
|
||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||
|
||||
function actionLabel(a: string) {
|
||||
return ACTION_LABEL[a] ?? a.charAt(0).toUpperCase() + a.slice(1);
|
||||
}
|
||||
function entityLabel(e: string) {
|
||||
return ENTITY_LABEL[e] ?? e;
|
||||
}
|
||||
|
||||
export function AuditTrailModal({
|
||||
rows,
|
||||
actorNames,
|
||||
closeHref,
|
||||
sub,
|
||||
}: {
|
||||
rows: AuditRow[];
|
||||
actorNames: Record<string, string>;
|
||||
closeHref: string;
|
||||
sub?: string;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title="Audit-Trail"
|
||||
sub={sub ?? "Nachvollziehbare Aktivitäten (neueste zuerst)"}
|
||||
closeHref={closeHref}
|
||||
closeLabel="Schließen"
|
||||
>
|
||||
<div className="max-h-[65vh] overflow-y-auto p-5">
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">Noch keine Einträge protokolliert.</p>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[12px] text-muted-foreground">
|
||||
<th className="py-2 pr-3 font-semibold">Zeitpunkt</th>
|
||||
<th className="py-2 pr-3 font-semibold">Akteur</th>
|
||||
<th className="py-2 pr-3 font-semibold">Aktion</th>
|
||||
<th className="py-2 pr-3 font-semibold">Objekt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.id} className="border-b last:border-0 align-top">
|
||||
<td className="whitespace-nowrap py-2 pr-3 text-xs text-muted-foreground">
|
||||
{fmt.format(r.createdAt)}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
{r.actorId ? actorNames[r.actorId] ?? "System" : "System"}
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<Pill tone={ACTION_TONE[r.action] ?? "mut"}>{actionLabel(r.action)}</Pill>
|
||||
</td>
|
||||
<td className="py-2 pr-3">
|
||||
<span className="font-medium">{entityLabel(r.entity)}</span>
|
||||
{r.entityId && (
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">
|
||||
#{r.entityId.slice(0, 8)}
|
||||
</span>
|
||||
)}
|
||||
{r.scope === "platform" && (
|
||||
<span className="ml-1 text-[11px] text-muted-foreground">· Plattform</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
changePasswordSelf,
|
||||
redeemInvitation,
|
||||
redeemPasswordReset,
|
||||
requestEmailChange,
|
||||
requestPasswordReset,
|
||||
type EmailChangeState,
|
||||
type RedeemResetState,
|
||||
type RequestResetState,
|
||||
type SelfChangeState,
|
||||
} from "@/server/actions/auth-recovery";
|
||||
|
||||
/**
|
||||
* SEC2 — Formulare des Passwort-Self-Service.
|
||||
*
|
||||
* Alle Rückmeldungen sind bewusst generisch gehalten (Enumeration-Schutz): das
|
||||
* Formular zeigt genau den Text, den die Server-Action liefert, und ergänzt ihn
|
||||
* nicht um Hinweise auf die Existenz eines Kontos.
|
||||
*/
|
||||
|
||||
const OK = "mt-4 rounded-lg bg-[rgba(57,192,127,0.14)] px-3 py-2 text-sm text-[var(--ok)]";
|
||||
const ERR = "mt-4 rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]";
|
||||
|
||||
export function ForgotPasswordForm({ domain }: { domain: "tenant" | "platform" }) {
|
||||
const [state, action, pending] = useActionState<RequestResetState, FormData>(
|
||||
requestPasswordReset,
|
||||
{ status: "idle" },
|
||||
);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<p role="status" className={OK}>
|
||||
{state.message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<div>
|
||||
<Label htmlFor="email">E-Mail-Adresse</Label>
|
||||
<Input id="email" name="email" type="email" required autoComplete="email" className="mt-1" />
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className={ERR}>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Sende…" : "Link anfordern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetPasswordForm({
|
||||
token,
|
||||
domain,
|
||||
policyHint,
|
||||
loginPath,
|
||||
variant = "reset",
|
||||
}: {
|
||||
token: string;
|
||||
domain: "tenant" | "platform";
|
||||
policyHint: string;
|
||||
loginPath: string;
|
||||
/** "invite" nutzt den Einladungs-Flow (Option C, WS3) statt des Reset-Flows. */
|
||||
variant?: "reset" | "invite";
|
||||
}) {
|
||||
const [state, action, pending] = useActionState<RedeemResetState, FormData>(
|
||||
variant === "invite" ? redeemInvitation : redeemPasswordReset,
|
||||
{ status: "idle" },
|
||||
);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<p role="status" className={OK}>
|
||||
{variant === "invite"
|
||||
? "Ihr Konto ist eingerichtet. Sie können sich jetzt anmelden."
|
||||
: "Ihr Passwort wurde gesetzt. Alle bisherigen Sitzungen sind abgemeldet."}
|
||||
</p>
|
||||
{/* Bewusst kein Auto-Login: der Nutzer meldet sich neu an (SEC2 §6). */}
|
||||
<Button nativeButton={false} render={<a href={loginPath} />} className="mt-4 w-full justify-center">
|
||||
Zur Anmeldung
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="mt-6 space-y-4">
|
||||
<input type="hidden" name="token" value={token} />
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<div>
|
||||
<Label htmlFor="password">Neues Passwort</Label>
|
||||
<Input id="password" name="password" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">{policyHint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirm">Wiederholen</Label>
|
||||
<Input id="confirm" name="confirm" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className={ERR}>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" className="w-full" disabled={pending}>
|
||||
{pending ? "Speichere…" : variant === "invite" ? "Konto einrichten" : "Passwort setzen"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangePasswordSelfForm({
|
||||
domain,
|
||||
policyHint,
|
||||
}: {
|
||||
domain: "tenant" | "platform";
|
||||
policyHint: string;
|
||||
}) {
|
||||
const [state, action, pending] = useActionState<SelfChangeState, FormData>(changePasswordSelf, {
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<div>
|
||||
<Label htmlFor="current">Aktuelles Passwort</Label>
|
||||
<Input id="current" name="current" type="password" required autoComplete="current-password" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-password">Neues Passwort</Label>
|
||||
<Input id="new-password" name="password" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
<p className="mt-1 text-xs text-muted-foreground">{policyHint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-confirm">Wiederholen</Label>
|
||||
<Input id="new-confirm" name="confirm" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
</div>
|
||||
{state.status !== "idle" && (
|
||||
<p role="status" className={state.status === "ok" ? OK : ERR}>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={pending}>
|
||||
{pending ? "Speichere…" : "Passwort ändern"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChangeEmailForm({
|
||||
domain,
|
||||
currentEmail,
|
||||
}: {
|
||||
domain: "tenant" | "platform";
|
||||
currentEmail: string;
|
||||
}) {
|
||||
const [state, action, pending] = useActionState<EmailChangeState, FormData>(requestEmailChange, {
|
||||
status: "idle",
|
||||
});
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<div>
|
||||
<Label htmlFor="newEmail">Neue E-Mail-Adresse</Label>
|
||||
<Input id="newEmail" name="newEmail" type="email" required className="mt-1" placeholder={currentEmail} />
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Die Änderung wird erst wirksam, wenn Sie den Link in der neuen Adresse bestätigen.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="email-current">Aktuelles Passwort zur Bestätigung</Label>
|
||||
<Input id="email-current" name="current" type="password" required autoComplete="current-password" className="mt-1" />
|
||||
</div>
|
||||
{state.status !== "idle" && (
|
||||
<p role="status" className={state.status === "ok" ? OK : ERR}>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" variant="outline" disabled={pending}>
|
||||
{pending ? "Sende…" : "Bestätigungslink senden"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { BackupActionState } from "@/server/actions/backup-admin";
|
||||
|
||||
/**
|
||||
* Client-Formulare des Betreiber-Portals für Datensicherung/DSGVO. Rendern in
|
||||
* den <Modal>-Popups der Mandantenseite (?restore=1 / ?dsgvo=1). Die Server-
|
||||
* Actions kommen als (gebundene) Props; die Sicherheitskontrollen (Voll-Admin,
|
||||
* MFA-Step-up, getippte Bestätigung) werden serverseitig erzwungen — die UI
|
||||
* spiegelt sie nur wider.
|
||||
*/
|
||||
|
||||
type Action = (prev: BackupActionState, formData: FormData) => Promise<BackupActionState>;
|
||||
|
||||
export interface SnapshotOption {
|
||||
snapshotId: string;
|
||||
snapshotAt: string | null;
|
||||
totalRows: number | null;
|
||||
artifactTenantSlug: string | null;
|
||||
tenantMismatch: boolean;
|
||||
tables: { model: string; rows: number }[];
|
||||
}
|
||||
|
||||
function Feedback({ state }: { state: BackupActionState }) {
|
||||
if (state.status === "error") {
|
||||
return <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12.5px] text-destructive">{state.message}</p>;
|
||||
}
|
||||
if (state.status === "done") {
|
||||
return <p className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-[12.5px] text-emerald-700 dark:text-emerald-300">{state.message}</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Portal-Restore: Auswahl + Dry-run-Vorschau + TOTP + getippte Bestätigung. */
|
||||
export function RestoreModalBody({
|
||||
tenantId,
|
||||
tenantSlug,
|
||||
snapshots,
|
||||
action,
|
||||
mfaEnrolled,
|
||||
storeError = false,
|
||||
}: {
|
||||
tenantId: string;
|
||||
tenantSlug: string;
|
||||
snapshots: SnapshotOption[];
|
||||
action: Action;
|
||||
mfaEnrolled: boolean;
|
||||
/** true = Sicherungsspeicher (S3/MinIO) nicht erreichbar → anderer Hinweis als „keine Sicherung". */
|
||||
storeError?: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
|
||||
const [selected, setSelected] = useState<string>(snapshots[0]?.snapshotId ?? "");
|
||||
const preview = snapshots.find((s) => s.snapshotId === selected) ?? null;
|
||||
const expected = `RESTORE ${tenantSlug}`;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4 p-5">
|
||||
<input type="hidden" name="tenantId" value={tenantId} />
|
||||
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-[12px] text-amber-800 dark:text-amber-200">
|
||||
<strong>Destruktiv:</strong> Der Mandant wird gesperrt, sein Datenbestand ersetzt (Wipe + Reinsert).
|
||||
Ein Pre-Restore-Sicherheitsschnappschuss wird automatisch erstellt (reversibel). Ausführung im Worker.
|
||||
</div>
|
||||
|
||||
{storeError ? (
|
||||
<p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12.5px] text-destructive">
|
||||
Sicherungsspeicher nicht erreichbar (S3/MinIO nicht konfiguriert oder Bucket fehlt). Bitte die S3_*-Variablen und das Backup-Bucket prüfen.
|
||||
</p>
|
||||
) : snapshots.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Kein Sicherungspunkt vorhanden. Zuerst „Export jetzt“ ausführen.</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<Label>Sicherungspunkt</Label>
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto rounded-lg border p-1.5">
|
||||
{snapshots.map((s) => (
|
||||
<label key={s.snapshotId} className="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] hover:bg-muted">
|
||||
<input type="radio" name="snapshotId" value={s.snapshotId} checked={selected === s.snapshotId} onChange={() => setSelected(s.snapshotId)} />
|
||||
<span className="font-mono">{s.snapshotId}</span>
|
||||
<span className="text-muted-foreground">{s.snapshotAt ? new Date(s.snapshotAt).toLocaleString() : "—"} · {s.totalRows ?? "?"} Zeilen</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Read-only Dry-run/Vorschau VOR der Bestätigung */}
|
||||
{preview && (
|
||||
<div className="rounded-lg border bg-muted/40 p-3 text-[12px]">
|
||||
<p className="mb-1 font-semibold">Vorschau (Dry-run)</p>
|
||||
<p className="text-muted-foreground">
|
||||
Zielmandant: <span className="font-mono">{tenantSlug}</span> · Artefakt-Mandant: <span className="font-mono">{preview.artifactTenantSlug ?? "?"}</span>
|
||||
{preview.tenantMismatch && <span className="ml-1 font-semibold text-destructive">(Mismatch — würde abgewiesen)</span>}
|
||||
</p>
|
||||
<p className="text-muted-foreground">Snapshot: {preview.snapshotAt ? new Date(preview.snapshotAt).toLocaleString() : "—"} · {preview.totalRows ?? "?"} Zeilen gesamt</p>
|
||||
{preview.tables.length > 0 && (
|
||||
<p className="mt-1 text-muted-foreground">
|
||||
{preview.tables.slice(0, 8).map((t) => `${t.model}: ${t.rows}`).join(" · ")}
|
||||
{preview.tables.length > 8 ? " …" : ""}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaEnrolled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="restore-token">MFA-Code (Step-up)</Label>
|
||||
<Input id="restore-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="restore-confirm">Bestätigung — exakt <span className="font-mono">{expected}</span> eingeben</Label>
|
||||
<Input id="restore-confirm" name="confirm" placeholder={expected} autoComplete="off" required />
|
||||
</div>
|
||||
|
||||
<Feedback state={state} />
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="destructive" size="sm" disabled={pending || !selected || (preview?.tenantMismatch ?? false)}>
|
||||
{pending ? "Stelle ein…" : "Restore einstellen"}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** „Export jetzt" — On-demand-Sicherung. */
|
||||
export function ExportModalBody({ tenantId, action, mfaEnrolled }: { tenantId: string; action: Action; mfaEnrolled: boolean }) {
|
||||
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
|
||||
return (
|
||||
<div className="space-y-5 p-5">
|
||||
<form action={formAction} className="space-y-4">
|
||||
<input type="hidden" name="tenantId" value={tenantId} />
|
||||
<p className="text-[12.5px] text-muted-foreground">Erstellt einen neuen, verschlüsselten Sicherungspunkt im <strong>Objektspeicher</strong> (Worker) — für Restore und regelmäßige Server-Backups.</p>
|
||||
{mfaEnrolled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="export-token">MFA-Code (Step-up)</Label>
|
||||
<Input id="export-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
|
||||
</div>
|
||||
)}
|
||||
<Feedback state={state} />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" disabled={pending}>{pending ? "Stelle ein…" : "Export jetzt einstellen"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<p className="text-[12.5px] text-muted-foreground">Oder die Sicherung <strong>direkt auf deinen Rechner</strong> herunterladen — als verschlüsselte <code>.cvb</code>-Datei. Läuft inline (kein Worker/S3 nötig).</p>
|
||||
<a href={`/api/platform/backup/download?tenant=${tenantId}`} download className="inline-flex">
|
||||
<Button type="button" variant="outline" size="sm">Sicherung herunterladen</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SubjectOption { identityId: string; label: string }
|
||||
|
||||
/** DSGVO-Zustellung: Per-Mandant oder Per-Person, Ergebnis als signierter Link. */
|
||||
export function DsgvoModalBody({
|
||||
tenantId,
|
||||
action,
|
||||
subjects,
|
||||
mfaEnrolled,
|
||||
}: {
|
||||
tenantId: string;
|
||||
action: Action;
|
||||
subjects: SubjectOption[];
|
||||
mfaEnrolled: boolean;
|
||||
}) {
|
||||
const [state, formAction, pending] = useActionState<BackupActionState, FormData>(action, { status: "idle" });
|
||||
const [scope, setScope] = useState<"tenant" | "person">("tenant");
|
||||
return (
|
||||
<form action={formAction} className="space-y-4 p-5">
|
||||
<input type="hidden" name="tenantId" value={tenantId} />
|
||||
<div className="space-y-1.5">
|
||||
<Label>Umfang</Label>
|
||||
<div className="flex gap-4 text-[12.5px]">
|
||||
<label className="flex items-center gap-2"><input type="radio" name="scope" value="tenant" checked={scope === "tenant"} onChange={() => setScope("tenant")} /> Gesamter Mandant (Art. 20)</label>
|
||||
<label className="flex items-center gap-2"><input type="radio" name="scope" value="person" checked={scope === "person"} onChange={() => setScope("person")} /> Einzelperson (Art. 15/20)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{scope === "person" && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dsgvo-subject">Betroffene Person</Label>
|
||||
<select id="dsgvo-subject" name="subjectIdentityId" className="h-8 w-full rounded-lg border border-input bg-transparent px-2.5 text-[12.5px]" required={scope === "person"}>
|
||||
<option value="">— auswählen —</option>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.identityId} value={s.identityId}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaEnrolled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="dsgvo-token">MFA-Code (Step-up)</Label>
|
||||
<Input id="dsgvo-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" required />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">Der Download-Link ist zeitlich begrenzt und nur im Betreiber-Portal (angemeldet) abrufbar.</p>
|
||||
<Feedback state={state} />
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" disabled={pending}>{pending ? "Stelle ein…" : "DSGVO-Export einstellen"}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { BackupSettingsState, BackupTargetView } from "@/server/actions/backup-settings";
|
||||
|
||||
/**
|
||||
* Betreiber-UI für den Backup-Zielspeicher (Lane „Konfigurierbarer Backup-Zielspeicher").
|
||||
* Ziel wählen (Lokal/S3), Config pflegen, „Verbindung testen", speichern. Die
|
||||
* Sicherheitskontrollen (Voll-Admin, MFA-Step-up) erzwingt der Server — die UI
|
||||
* spiegelt sie nur (MFA-Feld nur bei eingerichteter MFA). Das S3-Secret verlässt
|
||||
* die DB nie im Klartext; ein leeres Secret-Feld behält das hinterlegte Secret.
|
||||
*/
|
||||
|
||||
type Action = (prev: BackupSettingsState, formData: FormData) => Promise<BackupSettingsState>;
|
||||
|
||||
function Feedback({ state }: { state: BackupSettingsState }) {
|
||||
if (state.status === "error") {
|
||||
return <p className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-[12.5px] text-destructive">{state.message}</p>;
|
||||
}
|
||||
if (state.status === "ok") {
|
||||
return <p className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 px-3 py-2 text-[12.5px] text-emerald-700 dark:text-emerald-300">{state.message}</p>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function BackupTargetForm({
|
||||
initial,
|
||||
saveAction,
|
||||
testAction,
|
||||
mfaEnrolled,
|
||||
}: {
|
||||
initial: BackupTargetView;
|
||||
saveAction: Action;
|
||||
testAction: Action;
|
||||
mfaEnrolled: boolean;
|
||||
}) {
|
||||
const [saveState, save, savePending] = useActionState<BackupSettingsState, FormData>(saveAction, { status: "idle" });
|
||||
const [testState, test, testPending] = useActionState<BackupSettingsState, FormData>(testAction, { status: "idle" });
|
||||
const [target, setTarget] = useState<"local" | "s3">(initial.backupTarget);
|
||||
|
||||
return (
|
||||
<form action={save} className="space-y-5">
|
||||
{/* Ziel-Auswahl */}
|
||||
<div className="space-y-1.5">
|
||||
<Label>Zielspeicher</Label>
|
||||
<div className="flex flex-wrap gap-4 text-[12.5px]">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="backupTarget" value="local" checked={target === "local"} onChange={() => setTarget("local")} /> Lokal (gemountetes Volume)
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="radio" name="backupTarget" value="s3" checked={target === "s3"} onChange={() => setTarget("s3")} /> S3 / MinIO
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Präzedenz zur Laufzeit: diese DB-Config → Env (S3_*/BACKUP_LOCAL_DIR) → lokaler Default <span className="font-mono">.backups</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{target === "local" ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-localdir">Ablagepfad (lokal)</Label>
|
||||
<Input id="bk-localdir" name="backupLocalDir" defaultValue={initial.backupLocalDir} placeholder="/app/.backups" autoComplete="off" />
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Muss auf ein <strong>persistentes, gemountetes</strong> Volume zeigen (sonst gehen Sicherungen beim Redeploy verloren). Empfohlen: <span className="font-mono">/app/.backups</span>.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5 sm:col-span-2">
|
||||
<Label htmlFor="bk-endpoint">Endpoint</Label>
|
||||
<Input id="bk-endpoint" name="backupS3Endpoint" defaultValue={initial.backupS3Endpoint} placeholder="https://s3.example.com" autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-bucket">Bucket</Label>
|
||||
<Input id="bk-bucket" name="backupS3Bucket" defaultValue={initial.backupS3Bucket} placeholder="certvia-backups" autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-region">Region</Label>
|
||||
<Input id="bk-region" name="backupS3Region" defaultValue={initial.backupS3Region} placeholder="us-east-1" autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-access">Access-Key</Label>
|
||||
<Input id="bk-access" name="backupS3AccessKey" defaultValue={initial.backupS3AccessKey} autoComplete="off" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-secret">Secret-Key</Label>
|
||||
<Input
|
||||
id="bk-secret"
|
||||
name="backupS3SecretKey"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={initial.hasS3Secret ? "•••••• (leer lassen = beibehalten)" : "Secret-Key eingeben"}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground">Wird verschlüsselt gespeichert (nie Klartext in der DB).</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mfaEnrolled && (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="bk-token">MFA-Code (Step-up)</Label>
|
||||
<Input id="bk-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" className="max-w-[180px]" />
|
||||
<p className="text-[11px] text-muted-foreground">Für „Verbindung testen“ und „Speichern“ erforderlich.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Feedback state={testState} />
|
||||
<Feedback state={saveState} />
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" size="sm" disabled={savePending || testPending}>{savePending ? "Speichere…" : "Speichern"}</Button>
|
||||
<Button type="submit" formAction={test} variant="outline" size="sm" disabled={savePending || testPending}>
|
||||
{testPending ? "Teste…" : "Verbindung testen"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,735 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Check, ChevronLeft, ChevronRight, Plus, X } from "lucide-react";
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { AssetForm } from "@/components/asset-form";
|
||||
import { InformationCombobox } from "@/app/(app)/onboarding/steps/information/information-combobox";
|
||||
import { assignAsset, setBiaStatus, unassignAsset } from "@/server/actions/processes";
|
||||
import { createPrimaryInformationAsset, createSecondaryCarrierAsset, saveInfoProtection } from "@/server/actions/structure";
|
||||
import { adoptProcessRisk } from "@/server/actions/risk-catalog";
|
||||
import { createProcessRisk, linkProcessRisk, rateProcessRisk } from "@/server/actions/risks";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import { assetTypeLabel, infoLabelLabel } from "@/lib/asset-labels";
|
||||
|
||||
const SCALE5 = [1, 2, 3, 4, 5] as const;
|
||||
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
|
||||
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
||||
const TOTAL_STEPS = 5;
|
||||
const INFO_TYPES = ["INFORMATION", "DATA"] as const;
|
||||
|
||||
/**
|
||||
* TISAX v3A — geführtes BIA-Popup je Prozess (Prozesshaus-Kachel öffnet es). Fünf
|
||||
* Schritte, die die früheren eigenständigen Onboarding-Schritte an der Prozess-Matrix
|
||||
* bündeln:
|
||||
* 1 Informationswert (primäres Asset INFORMATION) — Katalog-Vorschlag oder volle
|
||||
* Asset-Inventar-Maske (AssetForm) bzw. Dedup-Combobox.
|
||||
* 2 Sekundäre Assets / Träger (ProcessAsset SECONDARY).
|
||||
* 3 Schutzbedarf C/I/A am Informations-Asset (Maximumprinzip).
|
||||
* 4 Risiken je Prozess inkl. Bewertung (Eintritt × Auswirkung = Wert, Behandlung).
|
||||
* 5 Abschluss-Übersicht — kompletter Review, setzt process.biaStatus (Prozesshaus-Farbe).
|
||||
*
|
||||
* Server-gerendert, Zustand über searchParams (?bia=<id>&biaStep=1..5) — konsistent zum
|
||||
* bestehenden Modal-Muster (kein Client-State; Browser-Zurück schließt/blättert).
|
||||
*/
|
||||
export async function BiaPopup({
|
||||
processId,
|
||||
step,
|
||||
newForm,
|
||||
basePath = "/onboarding?step=processes",
|
||||
}: {
|
||||
processId: string;
|
||||
step: number;
|
||||
/**
|
||||
* Aktiver Teil-Dialog (searchParam `biaNew`): "info" (volle Info-Maske),
|
||||
* "carrier" (neues Träger-Asset), "risk" (neues Risiko). Wird über die Hostseite
|
||||
* durchgereicht; „Abbrechen" entfernt den Param wieder und schließt den Teil-Dialog.
|
||||
*/
|
||||
newForm?: string;
|
||||
/** Basisroute der Hostseite (die searchParams `bia`/`biaStep` werden angehängt). */
|
||||
basePath?: string;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const t = await getTranslations("bia");
|
||||
const tc = await getTranslations("common");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tRiskLevel = await getTranslations("riskLevel");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const canWrite = hasPermission(session, "bia:write");
|
||||
|
||||
const process = await db.process.findFirst({
|
||||
where: { id: processId },
|
||||
include: {
|
||||
owner: { select: { name: true } },
|
||||
bia: true,
|
||||
processAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
label: true,
|
||||
confidentiality: true,
|
||||
integrity: true,
|
||||
availability: true,
|
||||
owner: { select: { name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const closeHref = basePath;
|
||||
if (!process) {
|
||||
return (
|
||||
<Modal title={t("heading", { name: "?" })} closeHref={closeHref} closeLabel={tc("close")}>
|
||||
<p className="p-5 text-sm text-muted-foreground">{tc("none")}</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const current = Math.min(Math.max(step, 1), TOTAL_STEPS);
|
||||
const stepHref = (n: number) => `${basePath}&bia=${process.id}&biaStep=${n}`;
|
||||
// Teil-Dialog öffnen (searchParam `biaNew`); der cancelHref ist stets `stepHref(n)`
|
||||
// (ohne `biaNew`) — so schließt „Abbrechen" den Teil-Dialog sauber (Task 2).
|
||||
const formHref = (n: number, name: string) => `${stepHref(n)}&biaNew=${name}`;
|
||||
|
||||
const primaries = process.processAssets.filter(
|
||||
(pa) => pa.role === "PRIMARY" && INFO_TYPES.includes(pa.asset.type as (typeof INFO_TYPES)[number]),
|
||||
);
|
||||
const secondaries = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
|
||||
// Geerbtes Maximum (Maximumprinzip) aus den primären Werten — für Träger-Vererbung.
|
||||
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));
|
||||
|
||||
const linkedRisks = await db.risk.findMany({
|
||||
where: { processId: process.id },
|
||||
select: { id: true, refNo: true, title: true, likelihood: true, impact: true, score: true, treatment: true, status: true },
|
||||
orderBy: { score: "desc" },
|
||||
});
|
||||
|
||||
const users = canWrite
|
||||
? await db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } })
|
||||
: [];
|
||||
|
||||
const availableAssets =
|
||||
canWrite && current === 2
|
||||
? await db.asset.findMany({
|
||||
where: { id: { notIn: process.processAssets.map((pa) => pa.assetId) } },
|
||||
select: { id: true, name: true, type: true },
|
||||
orderBy: { name: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Bestehende Risiken, die diesem Prozess noch NICHT zugeordnet sind (Task 4a) —
|
||||
// ungebundene (processId null) und an anderen Prozessen hängende (Umhängen erlaubt).
|
||||
const availableRisks =
|
||||
canWrite && current === 4
|
||||
? await db.risk.findMany({
|
||||
where: { OR: [{ processId: null }, { processId: { not: process.id } }] },
|
||||
select: { id: true, refNo: true, title: true },
|
||||
orderBy: { refNo: "asc" },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Katalog-Vorschläge (über catalogCode / Name) für Träger-Typen. Info-Labels werden im
|
||||
// Info-Schritt nicht mehr abgefragt (redundant zum C/I/A-Schritt, Task 1).
|
||||
const catalog = process.catalogCode
|
||||
? await db.processCatalogEntry.findFirst({ where: { code: process.catalogCode } })
|
||||
: await db.processCatalogEntry.findFirst({ where: { name: process.name } });
|
||||
const suggestedCarrierTypes = (catalog?.suggestedAssetTypes ?? []).filter(
|
||||
(tp) => tp !== "INFORMATION" && tp !== "DATA",
|
||||
);
|
||||
|
||||
// Empfohlene Katalog-Risiken je Prozess (nur bei aktivem Risiko-Modul); tolerant.
|
||||
let suggestedRisks: { code: string; title: string; category: string; adopted: boolean }[] = [];
|
||||
if (current === 4) {
|
||||
const codes = catalog?.suggestedRiskCodes ?? [];
|
||||
if (codes.length > 0) {
|
||||
const [entries, adopted] = await Promise.all([
|
||||
db.riskCatalogEntry.findMany({ where: { code: { in: codes } }, select: { code: true, title: true, category: true } }),
|
||||
db.risk.findMany({ where: { catalogCode: { in: codes }, processId: process.id }, select: { catalogCode: true } }),
|
||||
]);
|
||||
const adoptedSet = new Set(adopted.map((r) => r.catalogCode));
|
||||
suggestedRisks = entries.map((e) => ({ ...e, adopted: adoptedSet.has(e.code) }));
|
||||
}
|
||||
}
|
||||
|
||||
// Checkliste für Fortschritt/Abschluss.
|
||||
const hasInfo = primaries.length > 0;
|
||||
const hasCarrier = secondaries.length > 0;
|
||||
const hasCia = primaries.some((pa) => pa.asset.confidentiality > 1 || pa.asset.integrity > 1 || pa.asset.availability > 1);
|
||||
const hasRisk = linkedRisks.length > 0;
|
||||
const doneCount = [hasInfo, hasCarrier, hasCia, hasRisk].filter(Boolean).length;
|
||||
const stepDone = [hasInfo, hasCarrier, hasCia, hasRisk, process.biaStatus !== "offen"];
|
||||
|
||||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("heading", { name: process.name })}
|
||||
sub={t("stepOf", { n: current, total: TOTAL_STEPS })}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tCat(process.category)}</Tag>
|
||||
<Pill tone={BIA_TONE[process.biaStatus] ?? "mut"}>{t(`status.${process.biaStatus}`)}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref={closeHref}
|
||||
closeLabel={tc("close")}
|
||||
footer={
|
||||
<>
|
||||
{current > 1 ? (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={stepHref(current - 1)} />}>
|
||||
<ChevronLeft className="size-4" /> {t("back")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{current < TOTAL_STEPS && (
|
||||
<Button nativeButton={false} render={<Link href={stepHref(current + 1)} />}>
|
||||
{t("next")} <ChevronRight className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Schritt-Navigation (klickbar) */}
|
||||
<div className="sticky top-0 z-10 flex flex-wrap gap-1.5 border-b bg-card/95 px-5 py-2.5 backdrop-blur">
|
||||
{([1, 2, 3, 4, 5] as const).map((n) => (
|
||||
<Link
|
||||
key={n}
|
||||
href={stepHref(n)}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 font-heading text-[12px] font-semibold ${
|
||||
n === current
|
||||
? "border-[var(--brand,var(--primary))] bg-[var(--surface-soft)] text-foreground"
|
||||
: "text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`grid size-4.5 place-items-center rounded-full text-[10px] ${
|
||||
stepDone[n - 1] ? "bg-[var(--ok)] text-white" : "border"
|
||||
}`}
|
||||
>
|
||||
{stepDone[n - 1] ? <Check className="size-3" /> : n}
|
||||
</span>
|
||||
{t(`s${n}Short`)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5 p-5">
|
||||
{/* ── Schritt 1: Informationswert ─────────────────────────────────── */}
|
||||
{current === 1 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("s1Title")}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s1Hint")}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("currentPrimaries")}</p>
|
||||
{primaries.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaries")}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{primaries.map((pa) => (
|
||||
<li
|
||||
key={pa.id}
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-3 text-sm"
|
||||
>
|
||||
<b className="flex-1">{pa.asset.name}</b>
|
||||
<Tag>{tType(pa.asset.type)}</Tag>
|
||||
{pa.asset.label !== "NONE" && <Pill tone="warn">{infoLabelLabel(pa.asset.label)}</Pill>}
|
||||
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||
{canWrite && (
|
||||
<form action={unassignAsset.bind(null, process.id, pa.id)}>
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<>
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("quickAdd")}</p>
|
||||
{/* Task 1: Typ- (Information/Daten) und Klassifizierungs-Dropdown ausgeblendet —
|
||||
im BIA-Info-Schritt ist es immer ein Informationswert (INFORMATION). */}
|
||||
<InformationCombobox processId={process.id} hideType hideLabel />
|
||||
</div>
|
||||
|
||||
{/* Task 2: Teil-Dialog über searchParam `biaNew=info`; „Abbrechen" (in der
|
||||
AssetForm) zeigt auf stepHref(1) ohne den Param und schließt so sauber. */}
|
||||
{newForm === "info" ? (
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="mb-1 text-[13px] font-semibold">{t("manualToggle")}</p>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("manualHint")}</p>
|
||||
<AssetForm
|
||||
action={createPrimaryInformationAsset.bind(null, process.id)}
|
||||
users={users}
|
||||
cancelHref={stepHref(1)}
|
||||
defaultType="INFORMATION"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(1, "info")} />}>
|
||||
<Plus className="size-4" /> {t("manualToggle")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Schritt 2: Sekundäre Assets / Träger ────────────────────────── */}
|
||||
{current === 2 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("s2Title")}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s2Hint")}</p>
|
||||
</div>
|
||||
|
||||
{suggestedCarrierTypes.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 text-[12px]">
|
||||
<span className="font-medium text-muted-foreground">{t("suggestedCarriers")}:</span>
|
||||
{suggestedCarrierTypes.map((tp) => (
|
||||
<Pill key={tp} tone="mut">{assetTypeLabel(tp)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("currentCarriers")}</p>
|
||||
{secondaries.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noCarriers")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{secondaries.map((pa) => (
|
||||
<span key={pa.id} className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm">
|
||||
<span className="font-medium">{pa.asset.name}</span>
|
||||
<Tag>{tType(pa.asset.type)}</Tag>
|
||||
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||
{canWrite && (
|
||||
<form action={unassignAsset.bind(null, process.id, pa.id)} className="flex">
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<div className="space-y-3">
|
||||
{/* (a) Bestehendes Asset als Träger zuordnen. */}
|
||||
{availableAssets.length > 0 ? (
|
||||
<form action={assignAsset.bind(null, process.id)} className="flex flex-wrap gap-2 rounded-xl border bg-card p-3">
|
||||
<input type="hidden" name="role" value="SECONDARY" />
|
||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||
{availableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name} · {tType(a.type)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
<Plus className="size-4" /> {t("assignCarrier")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noAvailable")}</p>
|
||||
)}
|
||||
|
||||
{/* (b) Task 3: NEUES Träger-Asset über die volle Asset-Inventar-Maske anlegen
|
||||
(SECONDARY). Teil-Dialog via `biaNew=carrier`; Abbrechen schließt ihn. */}
|
||||
{newForm === "carrier" ? (
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="mb-1 text-[13px] font-semibold">{t("newCarrier")}</p>
|
||||
<p className="mt-1 mb-3 text-[11.5px] text-muted-foreground">{t("newCarrierHint")}</p>
|
||||
<AssetForm
|
||||
action={createSecondaryCarrierAsset.bind(null, process.id)}
|
||||
users={users}
|
||||
cancelHref={stepHref(2)}
|
||||
defaultType="SYSTEM"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(2, "carrier")} />}>
|
||||
<Plus className="size-4" /> {t("newCarrier")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Schritt 3: Schutzbedarf C/I/A ───────────────────────────────── */}
|
||||
{current === 3 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("s3Title")}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s3Hint")}</p>
|
||||
</div>
|
||||
|
||||
{primaries.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaryForCia")}</p>
|
||||
) : (
|
||||
primaries.map((pa) => (
|
||||
<form
|
||||
key={pa.id}
|
||||
action={saveInfoProtection.bind(null, pa.asset.id)}
|
||||
className="rounded-xl border bg-card p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<b className="text-sm">{pa.asset.name}</b>
|
||||
<Tag>{tType(pa.asset.type)}</Tag>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", tAssets("confidentiality"), pa.asset.confidentiality],
|
||||
["integrity", tAssets("integrity"), pa.asset.integrity],
|
||||
["availability", tAssets("availability"), pa.asset.availability],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={name} defaultValue={value} low={tLevel("1")} high={tLevel("4")} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="mt-3">
|
||||
<Button type="submit" size="sm">{tc("save")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
))
|
||||
)}
|
||||
|
||||
{secondaries.length > 0 && (
|
||||
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-3 text-[12.5px] text-[var(--band-text)]">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("inheritedMax")}: <CiaBadge c={maxC} i={maxI} a={maxA} labels />
|
||||
</span>
|
||||
<p className="mt-1">{t("inheritedHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Schritt 4: Risiken inkl. Bewertung ──────────────────────────── */}
|
||||
{current === 4 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("s4Title")}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s4Hint")}</p>
|
||||
</div>
|
||||
|
||||
{suggestedRisks.length > 0 && (
|
||||
<div className="rounded-xl border bg-card p-3">
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("suggestedRisks")}</p>
|
||||
<ul className="space-y-2">
|
||||
{suggestedRisks.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">{t("adopted")}</Pill>
|
||||
) : canWrite ? (
|
||||
<form action={adoptProcessRisk.bind(null, process.id, r.code)}>
|
||||
<Button type="submit" size="sm" variant="outline">{t("adopt")}</Button>
|
||||
</form>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("currentRisks")}</p>
|
||||
{linkedRisks.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noRisks")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{linkedRisks.map((r) => (
|
||||
<form
|
||||
key={r.id}
|
||||
action={rateProcessRisk.bind(null, process.id, r.id)}
|
||||
className="rounded-xl border bg-card p-3"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<b className="text-[13px]">{riskRef(r.refNo)}</b>
|
||||
<span className="flex-1 text-[13px]">{r.title}</span>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
|
||||
{r.score} · {tRiskLevel(riskLevel(r.score))}
|
||||
</Pill>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<Label htmlFor={`l-${r.id}`}>{t("likelihood")}</Label>
|
||||
<select id={`l-${r.id}`} name="likelihood" defaultValue={r.likelihood} className={`${selectClass} mt-1 w-full`}>
|
||||
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`i-${r.id}`}>{t("impact")}</Label>
|
||||
<select id={`i-${r.id}`} name="impact" defaultValue={r.impact} className={`${selectClass} mt-1 w-full`}>
|
||||
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`t-${r.id}`}>{t("treatment")}</Label>
|
||||
<select id={`t-${r.id}`} name="treatment" defaultValue={r.treatment} className={`${selectClass} mt-1 w-full`}>
|
||||
{TREATMENTS.map((v) => <option key={v} value={v}>{tTreat(v)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor={`s-${r.id}`}>{t("statusField")}</Label>
|
||||
<select id={`s-${r.id}`} name="status" defaultValue={r.status} className={`${selectClass} mt-1 w-full`}>
|
||||
{STATUSES.map((v) => <option key={v} value={v}>{tStatus(v)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<Button type="submit" size="sm">{t("rate")}</Button>
|
||||
<Link href={`/risks?detail=${r.id}`} className="text-[12px] font-semibold text-[var(--info)] hover:underline">
|
||||
{t("openInRiskModule")} →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<div className="space-y-3">
|
||||
{/* (a) Task 4a: bestehendes Risiko diesem Prozess zuordnen. */}
|
||||
{availableRisks.length > 0 && (
|
||||
<form action={linkProcessRisk.bind(null, process.id)} className="flex flex-wrap gap-2 rounded-xl border bg-card p-3">
|
||||
<select name="riskId" required className={`${selectClass} flex-1`}>
|
||||
{availableRisks.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{riskRef(r.refNo)} · {r.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
<Plus className="size-4" /> {t("linkRisk")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* (b) Task 4b: NEUES Risiko anlegen inkl. Bewertung (Eintritt × Auswirkung).
|
||||
Teil-Dialog via `biaNew=risk`; Abbrechen schließt ihn (Task 2). */}
|
||||
{newForm === "risk" ? (
|
||||
<form action={createProcessRisk.bind(null, process.id)} className="space-y-3 rounded-xl border bg-card p-3">
|
||||
<p className="text-[13px] font-semibold">{t("newRisk")}</p>
|
||||
<div>
|
||||
<Label htmlFor="new-risk-title">{t("riskTitle")}</Label>
|
||||
<Input id="new-risk-title" name="title" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-risk-desc">{t("riskDescription")}</Label>
|
||||
<Textarea id="new-risk-desc" name="description" rows={2} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div>
|
||||
<Label htmlFor="new-risk-l">{t("likelihood")}</Label>
|
||||
<select id="new-risk-l" name="likelihood" defaultValue={3} className={`${selectClass} mt-1 w-full`}>
|
||||
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-risk-i">{t("impact")}</Label>
|
||||
<select id="new-risk-i" name="impact" defaultValue={3} className={`${selectClass} mt-1 w-full`}>
|
||||
{SCALE5.map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-risk-t">{t("treatment")}</Label>
|
||||
<select id="new-risk-t" name="treatment" defaultValue="MITIGATE" className={`${selectClass} mt-1 w-full`}>
|
||||
{TREATMENTS.map((v) => <option key={v} value={v}>{tTreat(v)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="new-risk-s">{t("statusField")}</Label>
|
||||
<select id="new-risk-s" name="status" defaultValue="OPEN" className={`${selectClass} mt-1 w-full`}>
|
||||
{STATUSES.map((v) => <option key={v} value={v}>{tStatus(v)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm">{tc("save")}</Button>
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={stepHref(4)} />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={formHref(4, "risk")} />}>
|
||||
<Plus className="size-4" /> {t("newRisk")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* ── Schritt 5: Abschluss-Übersicht ──────────────────────────────── */}
|
||||
{current === 5 && (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("s5Title")}</p>
|
||||
<p className="mt-0.5 text-[12.5px] text-muted-foreground">{t("s5Hint")}</p>
|
||||
</div>
|
||||
|
||||
{/* Checkliste */}
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
{(
|
||||
[
|
||||
[t("clInfo"), hasInfo],
|
||||
[t("clCarrier"), hasCarrier],
|
||||
[t("clCia"), hasCia],
|
||||
[t("clRisk"), hasRisk],
|
||||
] as const
|
||||
).map(([label, ok]) => (
|
||||
<div key={label} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<Pill tone={ok ? "ok" : "warn"}>{ok ? <Check className="size-3" /> : "•"} {ok ? t("captured") : t("open")}</Pill>
|
||||
<p className="mt-1.5 text-[12px]">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Review: Informationswerte + CIA */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("reviewInfo")}</p>
|
||||
{primaries.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noPrimaries")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{primaries.map((pa) => (
|
||||
<li key={pa.id} className="flex flex-wrap items-center gap-2 border-b pb-1.5 last:border-0">
|
||||
<b className="flex-1">{pa.asset.name}</b>
|
||||
{pa.asset.label !== "NONE" && <Pill tone="warn">{infoLabelLabel(pa.asset.label)}</Pill>}
|
||||
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review: Träger */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("reviewCarriers")}</p>
|
||||
{secondaries.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noCarriers")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<CiaLegend />
|
||||
{secondaries.map((pa) => (
|
||||
<span key={pa.id} className="inline-flex items-center gap-2 rounded-full border px-3 py-1">
|
||||
{pa.asset.name}
|
||||
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Review: Risiken */}
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<p className="mb-2 text-[13px] font-semibold">{t("reviewRisks")}</p>
|
||||
{linkedRisks.length === 0 ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("noRisks")}</p>
|
||||
) : (
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{linkedRisks.map((r) => (
|
||||
<li key={r.id} className="flex flex-wrap items-center gap-2 border-b pb-1.5 last:border-0">
|
||||
<b>{riskRef(r.refNo)}</b>
|
||||
<span className="flex-1">{r.title}</span>
|
||||
<Tag>{tTreat(r.treatment)}</Tag>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
|
||||
{r.score} · {tRiskLevel(riskLevel(r.score))}
|
||||
</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Abschluss → biaStatus setzen (Prozesshaus-Farbe) */}
|
||||
{canWrite && (
|
||||
<div className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
|
||||
<p className="text-[13px] font-semibold text-[var(--band-text)]">{t("finishTitle")}</p>
|
||||
<p className="mt-0.5 text-[12px] text-[var(--band-text)]">
|
||||
{t("finishHint", { done: doneCount, total: 4 })}
|
||||
</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<form action={setBiaStatus.bind(null, process.id)}>
|
||||
<input type="hidden" name="biaStatus" value="komplett" />
|
||||
<Button type="submit">
|
||||
<Check className="size-4" /> {t("markComplete")}
|
||||
</Button>
|
||||
</form>
|
||||
<form action={setBiaStatus.bind(null, process.id)}>
|
||||
<input type="hidden" name="biaStatus" value="teilweise" />
|
||||
<Button type="submit" variant="outline">{t("markPartial")}</Button>
|
||||
</form>
|
||||
{process.bia && (
|
||||
<span className="ml-auto inline-flex items-center gap-2 text-[12.5px] text-[var(--band-text)]">
|
||||
{t("criticality")}:{" "}
|
||||
<CriticalityPill level={process.bia.criticality} label={String(process.bia.criticality)} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Farbton der biaStatus-Pille (auch im Prozesshaus verwendet). */
|
||||
export const BIA_TONE: Record<string, "mut" | "warn" | "ok"> = {
|
||||
offen: "mut",
|
||||
teilweise: "warn",
|
||||
komplett: "ok",
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
import { BRAND, BRAND_COLORS } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* Certvia-Logo als Inline-SVG.
|
||||
*
|
||||
* Bewusst inline statt <Image src="…svg">: die Wortmarke setzt „Cert" in Poppins
|
||||
* Light und „via" in Poppins Bold. Ein extern geladenes SVG hat keinen Zugriff auf
|
||||
* die per next/font/local eingebundene Schrift (gehashter Family-Name) und würde
|
||||
* auf eine System-Schrift zurückfallen. Inline greift `var(--font-poppins)`.
|
||||
*
|
||||
* Zeichen-Konstruktion (unverändert aus dem Design-Paket, viewBox 64×64):
|
||||
* „C" nach rechts offen, der Haken liegt bündig in der Öffnung. Kein Verlauf.
|
||||
*
|
||||
* theme="light" → heller Grund (C Violett, Haken/„via" Magenta, „Cert" Anthrazit)
|
||||
* theme="dark" → dunkler Grund (C #7d6fd6, Haken #d17bcf, „Cert" weiß)
|
||||
*/
|
||||
|
||||
type LogoTheme = "light" | "dark";
|
||||
|
||||
const PALETTE: Record<LogoTheme, { arc: string; hook: string; word: string; accent: string }> = {
|
||||
light: {
|
||||
arc: BRAND_COLORS.violet,
|
||||
hook: BRAND_COLORS.magenta,
|
||||
word: BRAND_COLORS.anthracite,
|
||||
accent: BRAND_COLORS.magenta,
|
||||
},
|
||||
dark: {
|
||||
arc: BRAND_COLORS.violetOnDark,
|
||||
hook: BRAND_COLORS.magentaOnDark,
|
||||
word: BRAND_COLORS.white,
|
||||
accent: BRAND_COLORS.magentaOnDark,
|
||||
},
|
||||
};
|
||||
|
||||
/** Das Monogramm allein — Pfade aus `certvia-mark-*.svg`. */
|
||||
function Monogram({ arc, hook }: { arc: string; hook: string }) {
|
||||
return (
|
||||
<>
|
||||
<path
|
||||
d="M46 20 A17 17 0 1 0 46 44"
|
||||
fill="none"
|
||||
stroke={arc}
|
||||
strokeWidth={6}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M27 32 l6 6 l12 -15"
|
||||
fill="none"
|
||||
stroke={hook}
|
||||
strokeWidth={5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CertviaLogo({
|
||||
variant = "lockup",
|
||||
theme = "dark",
|
||||
height = 40,
|
||||
className,
|
||||
title = BRAND.name,
|
||||
}: {
|
||||
variant?: "lockup" | "mark";
|
||||
theme?: LogoTheme;
|
||||
/** Renderhöhe in px; die Breite ergibt sich aus dem Seitenverhältnis. */
|
||||
height?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const c = PALETTE[theme];
|
||||
|
||||
if (variant === "mark") {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 64 64"
|
||||
width={height}
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<Monogram arc={c.arc} hook={c.hook} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 380 96"
|
||||
width={(height * 380) / 96}
|
||||
height={height}
|
||||
role="img"
|
||||
aria-label={title}
|
||||
className={className}
|
||||
>
|
||||
<title>{title}</title>
|
||||
<g transform="translate(6,8) scale(1.25)">
|
||||
<Monogram arc={c.arc} hook={c.hook} />
|
||||
</g>
|
||||
<text
|
||||
x={112}
|
||||
y={62}
|
||||
fontSize={52}
|
||||
letterSpacing={-1}
|
||||
style={{ fontFamily: "var(--font-poppins), Poppins, 'Segoe UI', sans-serif" }}
|
||||
>
|
||||
<tspan fontWeight={300} fill={c.word}>
|
||||
Cert
|
||||
</tspan>
|
||||
<tspan fontWeight={700} fill={c.accent}>
|
||||
via
|
||||
</tspan>
|
||||
</text>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Image from "next/image";
|
||||
import { BRAND, BRAND_ASSETS } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* Dachmarken-Hinweis „Ein Produkt von GEFIM".
|
||||
*
|
||||
* Bewusste Leitplanke der Branding-Umstellung: Certvia ist die Produktmarke,
|
||||
* GEFIM bleibt Dachmarke und erscheint weiterhin in Fußzeilen, auf den
|
||||
* Anmeldeseiten sowie später in Export- und Mail-Fußzeilen.
|
||||
* Siehe docs/BRANDING-CERTVIA.md.
|
||||
*/
|
||||
export function PoweredByGefim({
|
||||
withLogo = true,
|
||||
className,
|
||||
}: {
|
||||
withLogo?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<p
|
||||
className={`flex items-center justify-center gap-2 text-[11px] text-muted-foreground ${className ?? ""}`}
|
||||
>
|
||||
<span>{BRAND.byline}</span>
|
||||
{withLogo && (
|
||||
<Image
|
||||
src={BRAND_ASSETS.parentLogo}
|
||||
alt={BRAND.parentBrand}
|
||||
// Seitenverhältnis der Quelldatei (1000×353)
|
||||
width={76}
|
||||
height={27}
|
||||
// Negativdarstellung auf dunklem Grund
|
||||
className="opacity-80 brightness-0 invert"
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CertviaLogo } from "@/components/brand/certvia-logo";
|
||||
import type { TenantBranding } from "@/lib/brand";
|
||||
|
||||
/**
|
||||
* Kopfbereichs-Marke eines Mandanten (Story S8).
|
||||
*
|
||||
* Default ist **Certvia**; ein mandanteneigenes Logo überschreibt nur, wenn es
|
||||
* gesetzt ist. Der Upload je Mandant kommt mit Admin Phase 2 (Objektspeicher) —
|
||||
* bis dahin liefert `resolveTenantBranding` konstant `logoUrl: null`, die
|
||||
* Verzweigung ist aber bereits vorhanden und muss dann nicht nachgezogen werden.
|
||||
*/
|
||||
export function TenantBrand({
|
||||
branding,
|
||||
height = 34,
|
||||
className,
|
||||
}: {
|
||||
branding: TenantBranding;
|
||||
height?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
if (branding.logoUrl) {
|
||||
return (
|
||||
// Mandanten-Logos liegen später im Objektspeicher (beliebige Domain) —
|
||||
// daher bewusst <img> statt next/image ohne Remote-Pattern-Konfiguration.
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={branding.logoUrl}
|
||||
alt={branding.productName}
|
||||
style={{ height, width: "auto" }}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <CertviaLogo variant="lockup" theme="dark" height={height} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
Position,
|
||||
useReactFlow,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import dagre from "@dagrejs/dagre";
|
||||
import { toPng } from "html-to-image";
|
||||
import {
|
||||
Boxes,
|
||||
Database,
|
||||
AppWindow,
|
||||
MapPin,
|
||||
Truck,
|
||||
Server,
|
||||
Package,
|
||||
FolderKanban,
|
||||
Users,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Crosshair,
|
||||
Search,
|
||||
Download,
|
||||
Zap,
|
||||
ZapOff,
|
||||
GitBranch as GitBranchIcon,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { UI_COLORS } from "@/lib/brand";
|
||||
import type { DependencyGraph, GraphNode, GraphNodeKind } from "@/server/dependency-graph";
|
||||
|
||||
const KIND_ICON: Record<GraphNodeKind, typeof Boxes> = {
|
||||
process: GitBranch,
|
||||
INFORMATION: FileText,
|
||||
SYSTEM: Boxes,
|
||||
APPLICATION: AppWindow,
|
||||
LOCATION: MapPin,
|
||||
SUPPLIER: Truck,
|
||||
IT_SERVICE: Server,
|
||||
SOFTWARE: Package,
|
||||
PROJECT: FolderKanban,
|
||||
PERSON: Users,
|
||||
DATA: Database,
|
||||
};
|
||||
|
||||
type NodeData = GraphNode & { dimmed: boolean; kindLabel: string };
|
||||
|
||||
/** Custom-Node im Topology-Look (Referenz ISMS-Abhaengigkeitskarte). */
|
||||
function IsmsNode({ data }: NodeProps<Node<NodeData>>) {
|
||||
const Icon = KIND_ICON[data.kind] ?? Boxes;
|
||||
const critColor =
|
||||
data.criticality >= 4
|
||||
? "var(--risk)"
|
||||
: data.criticality >= 3
|
||||
? "var(--warn)"
|
||||
: "var(--ok)";
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-49 items-center gap-2.5 rounded-2xl border px-3 py-2.5 transition-all",
|
||||
"bg-[var(--panel)] backdrop-blur",
|
||||
data.critical
|
||||
? "border-[rgba(255,107,107,0.5)] shadow-[0_0_0_1px_rgba(255,107,107,0.25),0_8px_26px_rgba(255,80,80,0.12)]"
|
||||
: "border-[var(--panel-brd)]",
|
||||
data.dimmed && "opacity-25"
|
||||
)}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-[var(--muted-foreground)]" />
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-9 shrink-0 place-items-center rounded-xl",
|
||||
data.critical
|
||||
? "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]"
|
||||
: "bg-[var(--ui-primary-soft)] text-[var(--ui-blue)]"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4.5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-[13px] font-semibold text-[var(--txt)]">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="inline-block size-2 rounded-full" style={{ background: critColor }} />
|
||||
{data.kindLabel} · K{data.criticality}
|
||||
{data.spof && (
|
||||
<span className="ml-0.5 rounded bg-[rgba(255,107,107,0.18)] px-1.5 py-px text-[9.5px] font-bold text-[var(--risk)]">
|
||||
SPOF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Right} className="!bg-[var(--muted-foreground)]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { isms: IsmsNode };
|
||||
|
||||
function layout(graph: DependencyGraph, kindLabels: Record<string, string>) {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
g.setGraph({ rankdir: "LR", nodesep: 34, ranksep: 130, marginx: 20, marginy: 20 });
|
||||
graph.nodes.forEach((n) => g.setNode(n.id, { width: 200, height: 64 }));
|
||||
graph.edges.forEach((e) => g.setEdge(e.source, e.target));
|
||||
dagre.layout(g);
|
||||
|
||||
const nodes: Node<NodeData>[] = graph.nodes.map((n) => {
|
||||
const p = g.node(n.id);
|
||||
return {
|
||||
id: n.id,
|
||||
type: "isms",
|
||||
position: { x: p.x - 100, y: p.y - 32 },
|
||||
data: { ...n, dimmed: false, kindLabel: kindLabels[n.kind] ?? n.kind },
|
||||
};
|
||||
});
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function Canvas({
|
||||
graph,
|
||||
kindLabels,
|
||||
labels,
|
||||
}: {
|
||||
graph: DependencyGraph;
|
||||
kindLabels: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
const rf = useReactFlow();
|
||||
const [showCritical, setShowCritical] = useState(true);
|
||||
const [onlyProcesses, setOnlyProcesses] = useState(false);
|
||||
const [focus, setFocus] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
// Optionaler Filter „nur Prozesse": reduziert die Karte auf Prozesse und ihre
|
||||
// Prozess-zu-Prozess-Abhängigkeiten (blendet Assets/Träger aus).
|
||||
const view = useMemo(() => {
|
||||
if (!onlyProcesses) return graph;
|
||||
const procIds = new Set(graph.nodes.filter((n) => n.entity === "process").map((n) => n.id));
|
||||
return {
|
||||
...graph,
|
||||
nodes: graph.nodes.filter((n) => procIds.has(n.id)),
|
||||
edges: graph.edges.filter((e) => procIds.has(e.source) && procIds.has(e.target)),
|
||||
};
|
||||
}, [graph, onlyProcesses]);
|
||||
|
||||
const baseNodes = useMemo(() => layout(view, kindLabels), [view, kindLabels]);
|
||||
|
||||
// Nach Filterwechsel neu einpassen.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => rf.fitView({ duration: 400, padding: 0.15 }), 60);
|
||||
return () => clearTimeout(id);
|
||||
}, [onlyProcesses, rf]);
|
||||
|
||||
// Zusammenhang für Fokus-Highlight (ungerichtet)
|
||||
const neighbors = useMemo(() => {
|
||||
const m = new Map<string, Set<string>>();
|
||||
for (const e of view.edges) {
|
||||
if (!m.has(e.source)) m.set(e.source, new Set());
|
||||
if (!m.has(e.target)) m.set(e.target, new Set());
|
||||
m.get(e.source)!.add(e.target);
|
||||
m.get(e.target)!.add(e.source);
|
||||
}
|
||||
return m;
|
||||
}, [view.edges]);
|
||||
|
||||
const focusSet = useMemo(() => {
|
||||
if (!focus) return null;
|
||||
const seen = new Set<string>([focus]);
|
||||
const stack = [focus];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
for (const nb of neighbors.get(cur) ?? []) {
|
||||
if (!seen.has(nb)) {
|
||||
seen.add(nb);
|
||||
stack.push(nb);
|
||||
}
|
||||
}
|
||||
}
|
||||
return seen;
|
||||
}, [focus, neighbors]);
|
||||
|
||||
const nodes: Node<NodeData>[] = useMemo(
|
||||
() =>
|
||||
baseNodes.map((n) => ({
|
||||
...n,
|
||||
data: { ...n.data, dimmed: focusSet ? !focusSet.has(n.id) : false },
|
||||
})),
|
||||
[baseNodes, focusSet]
|
||||
);
|
||||
|
||||
const edges: Edge[] = useMemo(
|
||||
() =>
|
||||
view.edges.map((e) => {
|
||||
const isCrit = e.critical && showCritical;
|
||||
const inFocus = !focusSet || (focusSet.has(e.source) && focusSet.has(e.target));
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
type: "smoothstep",
|
||||
animated: isCrit,
|
||||
className: isCrit ? "critical-edge" : undefined,
|
||||
labelStyle: { fill: "var(--txt-muted)", fontSize: 10, fontWeight: 600 },
|
||||
labelBgStyle: { fill: "var(--bg-1)" },
|
||||
style: {
|
||||
stroke: isCrit ? "var(--risk)" : "var(--graph-edge)",
|
||||
strokeWidth: isCrit ? 2.5 : 1.5,
|
||||
opacity: inFocus ? 1 : 0.12,
|
||||
},
|
||||
};
|
||||
}),
|
||||
[view.edges, showCritical, focusSet]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((_: unknown, node: Node) => setFocus(node.id), []);
|
||||
const onPaneClick = useCallback(() => setFocus(null), []);
|
||||
|
||||
const runSearch = useCallback(
|
||||
(q: string) => {
|
||||
setQuery(q);
|
||||
if (!q.trim()) return;
|
||||
const hit = baseNodes.find((n) => n.data.name.toLowerCase().includes(q.toLowerCase()));
|
||||
if (hit) {
|
||||
setFocus(hit.id);
|
||||
rf.setCenter(hit.position.x + 100, hit.position.y + 32, { zoom: 1.2, duration: 500 });
|
||||
}
|
||||
},
|
||||
[baseNodes, rf]
|
||||
);
|
||||
|
||||
const exportPng = useCallback(async () => {
|
||||
const el = document.querySelector<HTMLElement>(".react-flow__viewport");
|
||||
if (!el) return;
|
||||
const dataUrl = await toPng(el, {
|
||||
// html-to-image rendert außerhalb des CSS-Kontexts der Seite — CSS-Variablen
|
||||
// sind hier nicht auflösbar, daher der Literal-Wert aus src/lib/brand.ts.
|
||||
backgroundColor: UI_COLORS.bg0,
|
||||
filter: (node) =>
|
||||
!(node instanceof HTMLElement && node.classList?.contains("react-flow__controls")),
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = dataUrl;
|
||||
a.download = "abhaengigkeiten.png";
|
||||
a.click();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative h-[600px] overflow-hidden rounded-2xl border border-[var(--panel-brd)] bg-[var(--bg-1)]">
|
||||
{/* Toolbar */}
|
||||
<div className="absolute top-3 left-3 z-10 flex flex-wrap items-center gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 backdrop-blur">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => runSearch(e.target.value)}
|
||||
placeholder={labels.search}
|
||||
className="w-40 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCritical((v) => !v)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[13px] font-semibold backdrop-blur",
|
||||
showCritical
|
||||
? "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.12)] text-[#ff6b6b]"
|
||||
: "border-[var(--panel-brd)] bg-[var(--panel)] text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{showCritical ? <Zap className="size-4" /> : <ZapOff className="size-4" />}
|
||||
{labels.criticalToggle}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setOnlyProcesses((v) => !v)}
|
||||
title={labels.onlyProcesses}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-[13px] font-semibold backdrop-blur",
|
||||
onlyProcesses
|
||||
? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)] text-[var(--ui-blue)]"
|
||||
: "border-[var(--panel-brd)] bg-[var(--panel)] text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<GitBranchIcon className="size-4" />
|
||||
{labels.onlyProcesses}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rf.fitView({ duration: 500, padding: 0.15 })}
|
||||
title={labels.fit}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
|
||||
>
|
||||
<Crosshair className="size-4" /> {labels.fit}
|
||||
</button>
|
||||
<button
|
||||
onClick={exportPng}
|
||||
title="PNG"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[var(--panel-brd)] bg-[var(--panel)] px-3 py-1.5 text-[13px] font-semibold text-muted-foreground backdrop-blur hover:text-foreground"
|
||||
>
|
||||
<Download className="size-4" /> PNG
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={26} size={1} color="rgba(140,155,200,0.18)" />
|
||||
<Controls className="!border-[var(--panel-brd)] !bg-[var(--panel)]" showInteractive={false} />
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DependencyGraphView(props: {
|
||||
graph: DependencyGraph;
|
||||
kindLabels: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
}) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<Canvas {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Pillen-Tabs wie im Mockup (Filter per Link/searchParams, serverseitig). */
|
||||
export function FilterTabs({
|
||||
tabs,
|
||||
}: {
|
||||
tabs: { href: string; label: string; active: boolean }[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.href}
|
||||
href={tab.href}
|
||||
className={cn(
|
||||
"rounded-full border px-3.5 py-[7px] font-heading text-[12.5px] font-semibold transition-colors",
|
||||
tab.active
|
||||
? "border-transparent bg-[var(--sidebar-accent)] text-[var(--primary)]"
|
||||
: "border-border bg-card text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { changeOwnPassword, type ChangePwState } from "@/server/actions/account";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const initial: ChangePwState = { status: "idle" };
|
||||
|
||||
export function ForcePasswordChangeForm({ policyHint }: { policyHint: string }) {
|
||||
const [state, action, pending] = useActionState(changeOwnPassword, initial);
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="password">Neues Passwort</Label>
|
||||
<Input id="password" name="password" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">Anforderungen: {policyHint}</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="confirm">Neues Passwort wiederholen</Label>
|
||||
<Input id="confirm" name="confirm" type="password" required autoComplete="new-password" className="mt-1" />
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
|
||||
)}
|
||||
<Button type="submit" disabled={pending} className="w-full">{pending ? "Speichere…" : "Passwort setzen & fortfahren"}</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { addRegisterRow, updateRegisterRow, deleteRegisterRow } from "@/server/actions/register";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* Generischer Editor für verwaltete Register (WP3.0): rendert die Pflichtspalten
|
||||
* als editierbare Zeilen inkl. optionaler Cross-Links (Lieferant/Asset).
|
||||
*/
|
||||
export interface RegisterColumn { key: string; label: string }
|
||||
export interface RegisterInfo {
|
||||
code: string; title: string; description: string | null;
|
||||
columns: RegisterColumn[]; supplierLink: boolean; assetLink: boolean;
|
||||
}
|
||||
export interface RegisterRowData { id: string; values: Record<string, string>; supplierRef: string | null; assetRef: string | null }
|
||||
export interface AssetOption { id: string; name: string }
|
||||
|
||||
const cell = "h-8 text-[12.5px]";
|
||||
|
||||
function RefSelect({ name, value, options, label }: { name: string; value: string | null; options: AssetOption[]; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-[11px] text-muted-foreground">{label}</label>
|
||||
<select name={name} defaultValue={value ?? ""} className="mt-0.5 h-8 w-full rounded-md border border-input bg-transparent px-2 text-[12.5px]">
|
||||
<option value="">—</option>
|
||||
{options.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Fields({ register, row, suppliers, assets, disabled }: {
|
||||
register: RegisterInfo; row?: RegisterRowData; suppliers: AssetOption[]; assets: AssetOption[]; disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{register.columns.map((c) => (
|
||||
<div key={c.key}>
|
||||
<label className="block text-[11px] text-muted-foreground">{c.label}</label>
|
||||
<Input name={`col_${c.key}`} defaultValue={row?.values?.[c.key] ?? ""} disabled={disabled} className={`mt-0.5 ${cell}`} />
|
||||
</div>
|
||||
))}
|
||||
{register.supplierLink && <RefSelect name="supplierRef" value={row?.supplierRef ?? null} options={suppliers} label="Lieferant" />}
|
||||
{register.assetLink && <RefSelect name="assetRef" value={row?.assetRef ?? null} options={assets} label="Asset" />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GenericRegisterView({ register, rows, canWrite, suppliers, assets }: {
|
||||
register: RegisterInfo; rows: RegisterRowData[]; canWrite: boolean; suppliers: AssetOption[]; assets: AssetOption[];
|
||||
}) {
|
||||
const gridCols = register.columns.length + (register.supplierLink ? 1 : 0) + (register.assetLink ? 1 : 0);
|
||||
const gridStyle = { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` };
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{register.description && <p className="text-[13px] text-muted-foreground">{register.description}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="rounded-xl border bg-card p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<form action={updateRegisterRow.bind(null, r.id)} className="grid flex-1 gap-2" style={gridStyle}>
|
||||
<Fields register={register} row={r} suppliers={suppliers} assets={assets} disabled={!canWrite} />
|
||||
{canWrite && <div className="col-span-full"><Button type="submit" variant="outline" size="sm">Speichern</Button></div>}
|
||||
</form>
|
||||
{canWrite && (
|
||||
<form action={deleteRegisterRow.bind(null, r.id)}>
|
||||
<Button type="submit" variant="ghost" size="sm" title="Löschen"><Trash2 className="size-4" /></Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Einträge in diesem Register.</p>}
|
||||
</div>
|
||||
|
||||
{canWrite && (
|
||||
<form action={addRegisterRow.bind(null, register.code)} className="rounded-xl border border-dashed bg-card p-3">
|
||||
<p className="mb-2 font-heading text-[12.5px] font-semibold">Eintrag hinzufügen</p>
|
||||
<div className="grid gap-2" style={gridStyle}>
|
||||
<Fields register={register} suppliers={suppliers} assets={assets} />
|
||||
</div>
|
||||
<Button type="submit" size="sm" className="mt-3"><Plus className="size-4" /> Hinzufügen</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
|
||||
/**
|
||||
* Live-Countdown der Vorfall-Fristen (IM-B, §6). Rendert die verbleibende Zeit je
|
||||
* Frist und hebt überfällige hervor. Rein clientseitig: die Fristen kommen als
|
||||
* ISO-Strings vom Server, der Tick aktualisiert nur die Anzeige.
|
||||
*/
|
||||
|
||||
export type DeadlineView = {
|
||||
kind: string;
|
||||
label: string;
|
||||
dueAt: string; // ISO
|
||||
done: boolean;
|
||||
meldung: boolean;
|
||||
};
|
||||
|
||||
export type DeadlineLabels = {
|
||||
meldungHead: string;
|
||||
slaHead: string;
|
||||
remaining: string;
|
||||
overdue: string;
|
||||
submitted: string;
|
||||
none: string;
|
||||
};
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
const abs = Math.abs(ms);
|
||||
const d = Math.floor(abs / 86_400_000);
|
||||
const h = Math.floor((abs % 86_400_000) / 3_600_000);
|
||||
const m = Math.floor((abs % 3_600_000) / 60_000);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(iso),
|
||||
);
|
||||
}
|
||||
|
||||
function DeadlineRow({ item, now, labels }: { item: DeadlineView; now: number; labels: DeadlineLabels }) {
|
||||
const ms = new Date(item.dueAt).getTime() - now;
|
||||
const overdue = !item.done && ms < 0;
|
||||
const soon = !item.done && ms >= 0 && ms <= 24 * 3_600_000;
|
||||
return (
|
||||
<li className="flex items-center justify-between gap-2 border-b py-1 text-[12px] last:border-0">
|
||||
<span className="min-w-0">
|
||||
<span className="font-medium">{item.label}</span>
|
||||
<span className="ml-2 text-muted-foreground">{fmtDate(item.dueAt)}</span>
|
||||
</span>
|
||||
{item.done ? (
|
||||
<Pill tone="ok">{labels.submitted}</Pill>
|
||||
) : overdue ? (
|
||||
<Pill tone="risk">
|
||||
{labels.overdue} {fmtDuration(ms)}
|
||||
</Pill>
|
||||
) : (
|
||||
<Pill tone={soon ? "warn" : "info"}>
|
||||
{labels.remaining} {fmtDuration(ms)}
|
||||
</Pill>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function IncidentDeadlines({
|
||||
items,
|
||||
labels,
|
||||
}: {
|
||||
items: DeadlineView[];
|
||||
labels: DeadlineLabels;
|
||||
}) {
|
||||
const [now, setNow] = useState<number>(() => Date.now());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(Date.now()), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-[12px] text-muted-foreground">{labels.none}</p>;
|
||||
}
|
||||
|
||||
const meldung = items.filter((i) => i.meldung);
|
||||
const sla = items.filter((i) => !i.meldung);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{meldung.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[12px] font-semibold">{labels.meldungHead}</p>
|
||||
<ul>
|
||||
{meldung.map((i) => (
|
||||
<DeadlineRow key={i.kind} item={i} now={now} labels={labels} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
{sla.length > 0 && (
|
||||
<div>
|
||||
<p className="mb-1 text-[12px] font-semibold">{labels.slaHead}</p>
|
||||
<ul>
|
||||
{sla.map((i) => (
|
||||
<DeadlineRow key={i.kind} item={i} now={now} labels={labels} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Lock, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addIncidentComment,
|
||||
advanceReportStatus,
|
||||
createEvidenceForIncident,
|
||||
createIncident,
|
||||
createMeasureForIncident,
|
||||
createRiskForIncident,
|
||||
linkIncidentAsset,
|
||||
linkIncidentControl,
|
||||
linkIncidentEvidence,
|
||||
linkIncidentMeasure,
|
||||
linkIncidentProcess,
|
||||
linkIncidentRisk,
|
||||
setIncidentOwners,
|
||||
setIncidentReportability,
|
||||
setIncidentRestricted,
|
||||
setIncidentReview,
|
||||
transitionIncident,
|
||||
unlinkIncidentAsset,
|
||||
unlinkIncidentControl,
|
||||
unlinkIncidentEvidence,
|
||||
unlinkIncidentMeasure,
|
||||
unlinkIncidentProcess,
|
||||
unlinkIncidentRisk,
|
||||
updateIncident,
|
||||
} from "@/server/actions/incidents";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill, Tag } from "@/components/mockup-ui";
|
||||
import { measureRef } from "@/lib/measure";
|
||||
import { riskRef } from "@/lib/risk";
|
||||
import {
|
||||
INCIDENT_CATEGORIES,
|
||||
INCIDENT_PRIORITIES,
|
||||
INCIDENT_SOURCES,
|
||||
REPORT_STATUS_TONE,
|
||||
SEVERITY_TONE,
|
||||
STATUS_TONE,
|
||||
STATUS_TRANSITIONS,
|
||||
TRANSITION_REQUIRES,
|
||||
type IncidentStatus,
|
||||
} from "@/lib/incident";
|
||||
import {
|
||||
deadlineItems,
|
||||
nextReportStatus,
|
||||
type ReportStatus,
|
||||
} from "@/lib/incident-deadlines";
|
||||
import { IncidentDeadlines, type DeadlineView } from "@/components/incident-deadlines";
|
||||
|
||||
export const INCIDENT_INCLUDE = {
|
||||
owner: { select: { id: true, name: true } },
|
||||
assignee: { select: { id: true, name: true } },
|
||||
incidentAssets: { include: { asset: { select: { id: true, name: true } } } },
|
||||
incidentProcesses: { include: { process: { select: { id: true, name: true } } } },
|
||||
incidentRisks: { include: { risk: { select: { id: true, refNo: true, title: true } } } },
|
||||
incidentControls: true,
|
||||
incidentMeasures: {
|
||||
include: {
|
||||
measure: {
|
||||
select: { id: true, refNo: true, title: true, status: true, dueDate: true, owner: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
incidentEvidence: { include: { evidence: { select: { id: true, title: true, kind: true } } } },
|
||||
comments: { orderBy: { createdAt: "asc" } },
|
||||
} as const;
|
||||
|
||||
export type IncidentWithDetail = Prisma.IncidentGetPayload<{ include: typeof INCIDENT_INCLUDE }>;
|
||||
|
||||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const SCALE04 = [0, 1, 2, 3, 4] as const;
|
||||
|
||||
/** Kurzlabel für den Maßnahmen-Status (aus dem zentralen Maßnahmen-Modul). */
|
||||
const MEASURE_STATUS_LABEL: Record<string, string> = {
|
||||
OPEN: "offen",
|
||||
IN_PROGRESS: "in Arbeit",
|
||||
DONE: "erledigt",
|
||||
};
|
||||
const tMeasureStatus = (s: string) => MEASURE_STATUS_LABEL[s] ?? s;
|
||||
|
||||
function fmtDate(d: Date | null): string {
|
||||
if (!d) return "—";
|
||||
return new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" }).format(d);
|
||||
}
|
||||
function toDatetimeLocal(d: Date | null): string {
|
||||
if (!d) return "";
|
||||
const off = d.getTimezoneOffset();
|
||||
return new Date(d.getTime() - off * 60000).toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
/** Gemeinsame Basisfelder für Anlegen + Bearbeiten. */
|
||||
async function IncidentFields({ incident }: { incident?: IncidentWithDetail }) {
|
||||
const t = await getTranslations("incidents");
|
||||
const tCat = await getTranslations("incidentCategory");
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="title">{t("titleField")}</Label>
|
||||
<Input id="title" name="title" required defaultValue={incident?.title} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea id="description" name="description" rows={3} defaultValue={incident?.description ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<select id="category" name="category" defaultValue={incident?.category ?? "other"} className={`${selectClass} mt-1 w-full`}>
|
||||
{INCIDENT_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>{tCat(c)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="source">{t("source")}</Label>
|
||||
<select id="source" name="source" defaultValue={incident?.source ?? "manual"} className={`${selectClass} mt-1 w-full`}>
|
||||
{INCIDENT_SOURCES.map((s) => (
|
||||
<option key={s} value={s}>{t(`source_${s}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reporterName">{t("reporterName")}</Label>
|
||||
<Input id="reporterName" name="reporterName" defaultValue={incident?.reporterName ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="reporterContact">{t("reporterContact")}</Label>
|
||||
<Input id="reporterContact" name="reporterContact" defaultValue={incident?.reporterContact ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="occurredAt">{t("occurredAt")}</Label>
|
||||
<Input id="occurredAt" name="occurredAt" type="datetime-local" defaultValue={toDatetimeLocal(incident?.occurredAt ?? null)} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="detectedAt">{t("detectedAt")}</Label>
|
||||
<Input id="detectedAt" name="detectedAt" type="datetime-local" defaultValue={toDatetimeLocal(incident?.detectedAt ?? null)} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[12px] font-semibold">{t("impactHead")}</p>
|
||||
<p className="mb-2 text-[11px] text-muted-foreground">{t("impactHint")}</p>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{(["impactC", "impactI", "impactA", "urgency"] as const).map((f) => (
|
||||
<div key={f}>
|
||||
<Label htmlFor={f}>{t(f)}</Label>
|
||||
<select id={f} name={f} defaultValue={incident?.[f] ?? (f === "urgency" ? 2 : 0)} className={`${selectClass} mt-1 w-full`}>
|
||||
{SCALE04.map((v) => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="priority">{t("priority")}</Label>
|
||||
<select id="priority" name="priority" defaultValue={incident?.priority ?? "mittel"} className={`${selectClass} mt-1 w-full`}>
|
||||
{INCIDENT_PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="affectedDataCategories">{t("dataCategories")}</Label>
|
||||
<Input id="affectedDataCategories" name="affectedDataCategories" placeholder={t("dataCategoriesHint")} defaultValue={(incident?.affectedDataCategories ?? []).join(", ")} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-5 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="personalData" defaultChecked={incident?.personalData ?? false} /> {t("personalData")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="prototypeData" defaultChecked={incident?.prototypeData ?? false} /> {t("prototypeData")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" name="nis2Relevant" defaultChecked={incident?.nis2Relevant ?? false} /> {t("nis2Relevant")}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-[11px] text-muted-foreground">{t("severityAutoHint")}</p>
|
||||
<input type="hidden" name="reportedAt" value={toDatetimeLocal(incident?.reportedAt ?? null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anlegen als Popup. */
|
||||
export async function IncidentCreateModal() {
|
||||
const t = await getTranslations("incidents");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} sub={t("createSub")} closeHref="/incidents" closeLabel={t("close")}>
|
||||
<form action={createIncident} className="space-y-4 p-5">
|
||||
<IncidentFields />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/incidents" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only Detail als Popup inkl. Statuswechsel, Timeline, Kommentaren, Verknüpfungen. */
|
||||
export async function IncidentDetailModal({
|
||||
incident,
|
||||
audit,
|
||||
actorNames,
|
||||
users,
|
||||
canManage,
|
||||
canClose,
|
||||
canReport,
|
||||
nis2Category,
|
||||
}: {
|
||||
incident: IncidentWithDetail;
|
||||
audit: { id: string; createdAt: Date; actorId: string | null; action: string; after: unknown }[];
|
||||
actorNames: Record<string, string>;
|
||||
users: { id: string; name: string }[];
|
||||
canManage: boolean;
|
||||
canClose: boolean;
|
||||
canReport: boolean;
|
||||
nis2Category: string;
|
||||
}) {
|
||||
const t = await getTranslations("incidents");
|
||||
const tCat = await getTranslations("incidentCategory");
|
||||
const tStatus = await getTranslations("incidentStatus");
|
||||
const tSev = await getTranslations("incidentSeverity");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const from = incident.status as IncidentStatus;
|
||||
const targets = STATUS_TRANSITIONS[from] ?? [];
|
||||
|
||||
// Fristen/Countdown (§6) — Werte serverseitig ableiten, live tickt der Client.
|
||||
const MELDUNG_KINDS = ["erstmeldung", "folgemeldung", "abschluss", "dsgvo"];
|
||||
const deadlineViews: DeadlineView[] = deadlineItems(incident).map((d) => ({
|
||||
kind: d.kind,
|
||||
label: t(`deadlineKind_${d.kind}`),
|
||||
dueAt: d.dueAt.toISOString(),
|
||||
done: d.done,
|
||||
meldung: MELDUNG_KINDS.includes(d.kind),
|
||||
}));
|
||||
const reportStatus = incident.reportStatus as ReportStatus;
|
||||
const nextReport = reportStatus !== "none" ? nextReportStatus(reportStatus) : null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${incident.refNo} · ${incident.title}`}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
{incident.restricted && (
|
||||
<Pill tone="risk">
|
||||
<Lock className="mr-1 inline size-3" /> {t("restricted")}
|
||||
</Pill>
|
||||
)}
|
||||
<Pill tone={SEVERITY_TONE[incident.severity as keyof typeof SEVERITY_TONE] ?? "mut"}>{tSev(incident.severity)}</Pill>
|
||||
<Pill tone={STATUS_TONE[from] ?? "mut"}>{tStatus(from)}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref="/incidents"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canManage && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/incidents?edit=${incident.id}`} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/incidents" />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten */}
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
{incident.description && <p className="mb-2 text-[12.5px] leading-relaxed">{incident.description}</p>}
|
||||
<dl className="grid grid-cols-[9rem_1fr] gap-1.5 text-[12.5px]">
|
||||
<dt className="text-muted-foreground">{t("category")}</dt>
|
||||
<dd><Tag>{tCat(incident.category)}</Tag></dd>
|
||||
<dt className="text-muted-foreground">{t("source")}</dt>
|
||||
<dd>{t(`source_${incident.source}`)}</dd>
|
||||
<dt className="text-muted-foreground">{t("reporter")}</dt>
|
||||
<dd>{incident.reporterName ?? tc("none")}{incident.reporterContact ? ` (${incident.reporterContact})` : ""}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{incident.owner?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("assignee")}</dt>
|
||||
<dd>{incident.assignee?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("occurredAt")}</dt>
|
||||
<dd>{fmtDate(incident.occurredAt)}</dd>
|
||||
<dt className="text-muted-foreground">{t("detectedAt")}</dt>
|
||||
<dd>{fmtDate(incident.detectedAt)}</dd>
|
||||
<dt className="text-muted-foreground">{t("impactCia")}</dt>
|
||||
<dd>C{incident.impactC} · I{incident.impactI} · A{incident.impactA} · {t("urgency")} {incident.urgency}</dd>
|
||||
<dt className="text-muted-foreground">{t("flags")}</dt>
|
||||
<dd className="flex flex-wrap gap-1">
|
||||
{incident.dsgvoRelevant && <Pill tone="warn">DSGVO</Pill>}
|
||||
{incident.prototypeData && <Pill tone="info">TISAX</Pill>}
|
||||
{incident.nis2Relevant && <Pill tone="orange">NIS2</Pill>}
|
||||
{!incident.dsgvoRelevant && !incident.prototypeData && !incident.nis2Relevant && tc("none")}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Steuerung: Status + Owner/Assignee + Vertraulichkeit */}
|
||||
<div className="space-y-4">
|
||||
{canManage && (
|
||||
<div className="rounded-xl border bg-card p-4">
|
||||
<p className="text-[13px] font-semibold">{t("statusChange")}</p>
|
||||
{targets.length === 0 ? (
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground">{t("noTransitions")}</p>
|
||||
) : (
|
||||
<form action={transitionIncident.bind(null, incident.id)} className="mt-2 space-y-2">
|
||||
<select name="status" className={`${selectClass} w-full`} required>
|
||||
{targets.map((s) => (
|
||||
<option key={s} value={s} disabled={s === "abgeschlossen" && !canClose}>
|
||||
{tStatus(s)}{TRANSITION_REQUIRES[s] ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{/* Pflichtfelder je Abschluss/Behebung (§8) — optional hier setzen, sonst müssen sie bereits existieren. */}
|
||||
<Textarea name="rootCause" rows={2} placeholder={t("rootCause")} defaultValue={incident.rootCause ?? ""} className="text-[12px]" />
|
||||
<Textarea name="resolution" rows={2} placeholder={t("resolution")} defaultValue={incident.resolution ?? ""} className="text-[12px]" />
|
||||
<Textarea name="closingNote" rows={2} placeholder={t("closingNote")} defaultValue={incident.closingNote ?? ""} className="text-[12px]" />
|
||||
<Textarea name="lessonsLearned" rows={2} placeholder={t("lessonsLearned")} defaultValue={incident.lessonsLearned ?? ""} className="text-[12px]" />
|
||||
<Button type="submit" size="sm">{t("applyStatus")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<form action={setIncidentOwners.bind(null, incident.id)} className="rounded-xl border bg-card p-4 space-y-2">
|
||||
<p className="text-[13px] font-semibold">{t("steering")}</p>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select id="ownerId" name="ownerId" defaultValue={incident.ownerId ?? ""} className={`${selectClass} mt-1 w-full`}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="assigneeId">{t("assignee")}</Label>
|
||||
<select id="assigneeId" name="assigneeId" defaultValue={incident.assigneeId ?? ""} className={`${selectClass} mt-1 w-full`}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="secondary">{tc("save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<form action={setIncidentRestricted.bind(null, incident.id)} className="rounded-xl border bg-card p-4">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="restricted" defaultChecked={incident.restricted} /> {t("restrictedToggle")}
|
||||
</label>
|
||||
<Button type="submit" size="sm" variant="outline" className="mt-2">{tc("save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Meldepflicht & Fristen (§6) — Countdown + Meldung-Track-Steuerung */}
|
||||
<div className="mx-5 mb-4 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[13px] font-semibold">{t("reportingHead")}</p>
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
{incident.nis2Relevant && <Pill tone="orange">NIS2</Pill>}
|
||||
{incident.dsgvoRelevant && <Pill tone="warn">DSGVO</Pill>}
|
||||
<Pill tone={REPORT_STATUS_TONE[incident.reportStatus] ?? "mut"}>
|
||||
{t(`reportStatus_${incident.reportStatus}`)}
|
||||
</Pill>
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{t("nis2CategoryLabel")}: {t(`nis2_${nis2Category}`)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-2">
|
||||
<IncidentDeadlines
|
||||
items={deadlineViews}
|
||||
labels={{
|
||||
meldungHead: t("dl_meldungHead"),
|
||||
slaHead: t("dl_slaHead"),
|
||||
remaining: t("dl_remaining"),
|
||||
overdue: t("dl_overdue"),
|
||||
submitted: t("dl_submitted"),
|
||||
none: t("dl_none"),
|
||||
}}
|
||||
/>
|
||||
|
||||
{canManage && (
|
||||
<div className="space-y-3">
|
||||
<form action={setIncidentReportability.bind(null, incident.id)} className="space-y-1.5 rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[12px] font-semibold">{t("setReportability")}</p>
|
||||
<label className="flex items-center gap-2 text-[12.5px]">
|
||||
<input type="checkbox" name="nis2Relevant" defaultChecked={incident.nis2Relevant} /> {t("nis2Relevant")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-[12.5px]">
|
||||
<input type="checkbox" name="personalData" defaultChecked={incident.personalData} /> {t("personalData")}
|
||||
</label>
|
||||
<Button type="submit" size="sm" variant="secondary">{t("applyReportability")}</Button>
|
||||
<p className="text-[11px] text-muted-foreground">{t("reportabilityHint")}</p>
|
||||
</form>
|
||||
|
||||
{incident.reportStatus !== "none" && nextReport && (
|
||||
<form action={advanceReportStatus.bind(null, incident.id)} className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<input type="hidden" name="reportStatus" value={nextReport} />
|
||||
<Button type="submit" size="sm" disabled={nextReport === "abschluss" && !canClose}>
|
||||
{t("advanceTo")} {t(`reportStatus_${nextReport}`)}
|
||||
</Button>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">{t("manualSubmitHint")}</p>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Behebung/Abschluss-Texte (read-only Anzeige) */}
|
||||
{(incident.rootCause || incident.resolution || incident.closingNote || incident.lessonsLearned || incident.measuresEffectiveness || incident.postIncidentReview) && (
|
||||
<div className="mx-5 mb-4 grid gap-3 rounded-xl border bg-[var(--surface-soft)] p-4 text-[12.5px] md:grid-cols-2">
|
||||
{incident.rootCause && <div><p className="font-semibold">{t("rootCause")}</p><p className="text-muted-foreground">{incident.rootCause}</p></div>}
|
||||
{incident.resolution && <div><p className="font-semibold">{t("resolution")}</p><p className="text-muted-foreground">{incident.resolution}</p></div>}
|
||||
{incident.closingNote && <div><p className="font-semibold">{t("closingNote")}</p><p className="text-muted-foreground">{incident.closingNote}</p></div>}
|
||||
{incident.lessonsLearned && <div><p className="font-semibold">{t("lessonsLearned")}</p><p className="text-muted-foreground">{incident.lessonsLearned}</p></div>}
|
||||
{incident.measuresEffectiveness && <div><p className="font-semibold">{t("measuresEffectiveness")}</p><p className="text-muted-foreground">{incident.measuresEffectiveness}</p></div>}
|
||||
{incident.postIncidentReview && <div><p className="font-semibold">{t("postIncidentReview")}</p><p className="text-muted-foreground">{incident.postIncidentReview}</p></div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Betroffenheit / Verknüpfungen (read-only Zusammenfassung; Pflege im Bearbeiten-Popup) */}
|
||||
<div className="mx-5 mb-4 grid gap-3 text-sm md:grid-cols-2">
|
||||
<LinkSummary label={t("assets")} items={incident.incidentAssets.map((x) => x.asset.name)} />
|
||||
<LinkSummary label={t("processes")} items={incident.incidentProcesses.map((x) => x.process.name)} />
|
||||
<LinkSummary label={t("risks")} items={incident.incidentRisks.map((x) => `${riskRef(x.risk.refNo)} ${x.risk.title}`)} />
|
||||
<LinkSummary label={t("controls")} items={incident.incidentControls.map((x) => x.controlRef)} />
|
||||
<LinkSummary
|
||||
label={t("measures")}
|
||||
items={incident.incidentMeasures.map(
|
||||
(x) =>
|
||||
`${measureRef(x.measure.refNo)} ${x.measure.title} · ${tMeasureStatus(x.measure.status)}` +
|
||||
`${x.measure.owner?.name ? ` · ${x.measure.owner.name}` : ""}` +
|
||||
`${x.measure.dueDate ? ` · ${fmtDate(x.measure.dueDate)}` : ""}`,
|
||||
)}
|
||||
/>
|
||||
<LinkSummary label={t("evidence")} items={incident.incidentEvidence.map((x) => x.evidence.title + (x.note ? ` — ${x.note}` : ""))} />
|
||||
</div>
|
||||
|
||||
{/* §8 — Post-Incident-Review + Wirksamkeit der Maßnahmen (speist Management-Review) */}
|
||||
{canManage && (
|
||||
<form action={setIncidentReview.bind(null, incident.id)} className="mx-5 mb-4 space-y-2 rounded-xl border bg-card p-4">
|
||||
<p className="text-[13px] font-semibold">{t("reviewHead")}</p>
|
||||
<p className="text-[11.5px] text-muted-foreground">{t("reviewHint")}</p>
|
||||
<div>
|
||||
<Label htmlFor="measuresEffectiveness">{t("measuresEffectiveness")}</Label>
|
||||
<Textarea id="measuresEffectiveness" name="measuresEffectiveness" rows={2} defaultValue={incident.measuresEffectiveness ?? ""} className="mt-1 text-[12px]" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="postIncidentReview">{t("postIncidentReview")}</Label>
|
||||
<Textarea id="postIncidentReview" name="postIncidentReview" rows={2} defaultValue={incident.postIncidentReview ?? ""} className="mt-1 text-[12px]" />
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="secondary">{tc("save")}</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Export / Nachweise (§9/§72) */}
|
||||
<div className="mx-5 mb-4 flex flex-wrap items-center gap-2 rounded-xl border bg-[var(--surface-soft)] p-4">
|
||||
<p className="mr-2 text-[13px] font-semibold">{t("exportHead")}</p>
|
||||
<a href={`/incidents/export?format=report&id=${incident.id}`} target="_blank" rel="noreferrer" className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
|
||||
{t("exportReport")}
|
||||
</a>
|
||||
{incident.nis2Relevant && (
|
||||
<a href={`/incidents/export?format=nis2&id=${incident.id}`} className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
|
||||
{t("exportNis2")}
|
||||
</a>
|
||||
)}
|
||||
{incident.dsgvoRelevant && (
|
||||
<a href={`/incidents/export?format=dsgvo&id=${incident.id}`} className="rounded-md border bg-card px-3 py-1.5 text-[12px] hover:bg-muted">
|
||||
{t("exportDsgvo")}
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[11px] text-muted-foreground">{t("exportHint")}</span>
|
||||
</div>
|
||||
|
||||
{/* Kommentar-Thread (§4) */}
|
||||
<div className="mx-5 mb-4">
|
||||
<p className="text-sm font-semibold">{t("comments")}</p>
|
||||
<ul className="mt-2 space-y-2">
|
||||
{incident.comments.length === 0 && <li className="text-sm text-muted-foreground">{t("noComments")}</li>}
|
||||
{incident.comments.map((c) => (
|
||||
<li key={c.id} className="rounded-lg border bg-[var(--surface-soft)] p-2.5 text-[12.5px]">
|
||||
<div className="mb-0.5 flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="font-semibold text-foreground">{c.authorId ? actorNames[c.authorId] ?? "—" : "System"}</span>
|
||||
<span>{fmtDate(c.createdAt)}</span>
|
||||
{c.internal && <Pill tone="mut">{t("internal")}</Pill>}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap">{c.body}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{canReport && (
|
||||
<form action={addIncidentComment.bind(null, incident.id)} className="mt-2 space-y-2">
|
||||
<Textarea name="body" rows={2} required placeholder={t("commentPlaceholder")} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" size="sm" variant="secondary"><Plus className="size-3.5" /> {t("addComment")}</Button>
|
||||
<label className="flex items-center gap-2 text-[12px]">
|
||||
<input type="checkbox" name="internal" /> {t("internal")}
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Audit-Timeline (§8) — manipulationssicher, getrennt von Kommentaren */}
|
||||
<div className="mx-5 mb-5">
|
||||
<p className="text-sm font-semibold">{t("timeline")}</p>
|
||||
<ul className="mt-2 space-y-1.5 text-[12px]">
|
||||
{audit.length === 0 && <li className="text-muted-foreground">{t("noTimeline")}</li>}
|
||||
{audit.map((a) => (
|
||||
<li key={a.id} className="flex flex-wrap items-center gap-2 border-b py-1 last:border-0">
|
||||
<span className="text-muted-foreground">{fmtDate(a.createdAt)}</span>
|
||||
<span className="font-medium">{a.actorId ? actorNames[a.actorId] ?? "System" : "System"}</span>
|
||||
<Tag>{a.action}</Tag>
|
||||
{a.after != null && typeof a.after === "object" && (
|
||||
<span className="text-muted-foreground">{JSON.stringify(a.after)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkSummary({ label, items }: { label: string; items: string[] }) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[12px] font-semibold">{label}</p>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-[11.5px] text-muted-foreground">—</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-0.5 text-[12px]">
|
||||
{items.map((i, idx) => <li key={idx}>{i}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten als Popup: Basisfelder + Verknüpfungspflege. */
|
||||
export async function IncidentEditModal({
|
||||
incident,
|
||||
availableAssets,
|
||||
availableProcesses,
|
||||
availableRisks,
|
||||
availableMeasures,
|
||||
availableEvidence,
|
||||
users,
|
||||
}: {
|
||||
incident: IncidentWithDetail;
|
||||
availableAssets: { id: string; name: string }[];
|
||||
availableProcesses: { id: string; name: string }[];
|
||||
availableRisks: { id: string; refNo: number; title: string }[];
|
||||
availableMeasures: { id: string; refNo: number; title: string }[];
|
||||
availableEvidence: { id: string; title: string }[];
|
||||
users: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("incidents");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<Modal title={t("editTitle")} sub={`${incident.refNo} · ${incident.title}`} closeHref={`/incidents?detail=${incident.id}`} closeLabel={t("close")}>
|
||||
<div className="grid gap-6 p-5 md:grid-cols-2">
|
||||
<form key={incident.updatedAt.toISOString()} action={updateIncident.bind(null, incident.id)} className="space-y-4">
|
||||
<IncidentFields incident={incident} />
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
<LinkEditor
|
||||
label={t("assets")} addLabel={t("addAsset")} incidentId={incident.id}
|
||||
items={incident.incidentAssets.map((x) => ({ id: x.id, name: x.asset.name }))}
|
||||
options={availableAssets.map((a) => ({ value: a.id, label: a.name }))}
|
||||
fieldName="assetId"
|
||||
add={linkIncidentAsset.bind(null, incident.id)}
|
||||
remove={unlinkIncidentAsset}
|
||||
/>
|
||||
<LinkEditor
|
||||
label={t("processes")} addLabel={t("addProcess")} incidentId={incident.id}
|
||||
items={incident.incidentProcesses.map((x) => ({ id: x.id, name: x.process.name }))}
|
||||
options={availableProcesses.map((p) => ({ value: p.id, label: p.name }))}
|
||||
fieldName="processId"
|
||||
add={linkIncidentProcess.bind(null, incident.id)}
|
||||
remove={unlinkIncidentProcess}
|
||||
/>
|
||||
{/* Risiken: bestehendes bestätigen ODER neu erzeugen (§9) */}
|
||||
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<LinkEditor
|
||||
label={t("risks")} addLabel={t("addRisk")} incidentId={incident.id}
|
||||
items={incident.incidentRisks.map((x) => ({ id: x.id, name: `${riskRef(x.risk.refNo)} ${x.risk.title}` }))}
|
||||
options={availableRisks.map((r) => ({ value: r.id, label: `${riskRef(r.refNo)} ${r.title}` }))}
|
||||
fieldName="riskId"
|
||||
add={linkIncidentRisk.bind(null, incident.id)}
|
||||
remove={unlinkIncidentRisk}
|
||||
/>
|
||||
<details className="text-[12px]">
|
||||
<summary className="cursor-pointer font-medium">{t("newRiskFromIncident")}</summary>
|
||||
<form action={createRiskForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
|
||||
<Input name="title" required placeholder={t("riskTitlePlaceholder")} className="h-9" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="risk-l" className="text-[11px]">{t("likelihood")}</Label>
|
||||
<select id="risk-l" name="likelihood" defaultValue={3} className={`${selectClass} w-16`}>{[1, 2, 3, 4, 5].map((v) => <option key={v} value={v}>{v}</option>)}</select>
|
||||
<Label htmlFor="risk-i" className="text-[11px]">{t("impact")}</Label>
|
||||
<select id="risk-i" name="impact" defaultValue={3} className={`${selectClass} w-16`}>{[1, 2, 3, 4, 5].map((v) => <option key={v} value={v}>{v}</option>)}</select>
|
||||
</div>
|
||||
<Button type="submit" variant="secondary" size="sm">{t("createRisk")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Maßnahmen: bestehende verknüpfen ODER direkt aus dem Vorfall anlegen (§9, CAPA) */}
|
||||
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<LinkEditor
|
||||
label={t("measures")} addLabel={t("addMeasure")} incidentId={incident.id}
|
||||
items={incident.incidentMeasures.map((x) => ({ id: x.id, name: `${measureRef(x.measure.refNo)} ${x.measure.title} · ${tMeasureStatus(x.measure.status)}` }))}
|
||||
options={availableMeasures.map((m) => ({ value: m.id, label: `${measureRef(m.refNo)} ${m.title}` }))}
|
||||
fieldName="measureId"
|
||||
add={linkIncidentMeasure.bind(null, incident.id)}
|
||||
remove={unlinkIncidentMeasure}
|
||||
/>
|
||||
<details className="text-[12px]">
|
||||
<summary className="cursor-pointer font-medium">{t("newMeasureFromIncident")}</summary>
|
||||
<form action={createMeasureForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
|
||||
<Input name="title" required placeholder={t("measureTitlePlaceholder")} className="h-9" />
|
||||
<div className="flex gap-2">
|
||||
<select name="priority" defaultValue="MEDIUM" className={`${selectClass} flex-1`}>
|
||||
<option value="LOW">{t("priorityLow")}</option>
|
||||
<option value="MEDIUM">{t("priorityMedium")}</option>
|
||||
<option value="HIGH">{t("priorityHigh")}</option>
|
||||
</select>
|
||||
<select name="ownerId" defaultValue="" className={`${selectClass} flex-1`}>
|
||||
<option value="">{t("measureOwnerNone")}</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Input name="dueDate" type="date" className="h-9" />
|
||||
<Button type="submit" variant="secondary" size="sm">{t("createMeasure")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Nachweise: bestehenden Nachweis verknüpfen ODER Referenz/Text anlegen (§9) */}
|
||||
<div className="space-y-2 rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="font-medium">{t("evidence")}</p>
|
||||
{incident.incidentEvidence.length === 0 && <p className="text-xs text-muted-foreground">—</p>}
|
||||
<ul className="space-y-1.5">
|
||||
{incident.incidentEvidence.map((e) => (
|
||||
<li key={e.id} className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate">{e.evidence.title}{e.note ? ` — ${e.note}` : ""}</span>
|
||||
<form action={unlinkIncidentEvidence.bind(null, incident.id, e.id)}>
|
||||
<button type="submit" className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{availableEvidence.length > 0 && (
|
||||
<form action={linkIncidentEvidence.bind(null, incident.id)} className="flex gap-2">
|
||||
<select name="evidenceId" required className={`${selectClass} flex-1`}>
|
||||
{availableEvidence.map((e) => <option key={e.id} value={e.id}>{e.title}</option>)}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
)}
|
||||
<details className="text-[12px]">
|
||||
<summary className="cursor-pointer font-medium">{t("newEvidence")}</summary>
|
||||
<form action={createEvidenceForIncident.bind(null, incident.id)} className="mt-2 space-y-2">
|
||||
<Input name="title" required placeholder={t("evidenceTitlePlaceholder")} className="h-9" />
|
||||
<Input name="fileRef" placeholder={t("evidenceRefPlaceholder")} className="h-9" />
|
||||
<Button type="submit" variant="secondary" size="sm">{t("createEvidence")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
{/* Controls: freie Referenzeingabe (kein Katalog-FK) */}
|
||||
<div>
|
||||
<p className="font-medium">{t("controls")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("controlsHint")}</p>
|
||||
<ul className="space-y-1.5">
|
||||
{incident.incidentControls.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-2">
|
||||
{c.controlRef}
|
||||
<form action={unlinkIncidentControl.bind(null, incident.id, c.id)}>
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<form action={linkIncidentControl.bind(null, incident.id)} className="mt-2 flex gap-2">
|
||||
<Input name="controlRef" required placeholder="z. B. 5.24" className="h-9 flex-1" />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-5 text-[12px] text-muted-foreground">
|
||||
<Trash2 className="mr-1 inline size-3.5" /> {t("deleteHint")}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkEditor({
|
||||
label,
|
||||
addLabel,
|
||||
incidentId,
|
||||
items,
|
||||
options,
|
||||
fieldName,
|
||||
add,
|
||||
remove,
|
||||
}: {
|
||||
label: string;
|
||||
addLabel: string;
|
||||
incidentId: string;
|
||||
items: { id: string; name: string }[];
|
||||
options: { value: string; label: string }[];
|
||||
fieldName: string;
|
||||
add: (formData: FormData) => void;
|
||||
remove: (incidentId: string, linkId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<p className="font-medium">{label}</p>
|
||||
{items.length === 0 && <p className="text-xs text-muted-foreground">—</p>}
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="flex items-center gap-2">
|
||||
<span className="min-w-0 truncate">{it.name}</span>
|
||||
<form action={remove.bind(null, incidentId, it.id)}>
|
||||
<button type="submit" className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{options.length > 0 && (
|
||||
<form action={add} className="mt-2 flex gap-2">
|
||||
<select name={fieldName} required className={`${selectClass} flex-1`}>
|
||||
{options.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">{addLabel}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
"use client";
|
||||
|
||||
import { useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type KanbanCard = {
|
||||
id: string;
|
||||
refLabel: string;
|
||||
title: string;
|
||||
dueLabel: string | null;
|
||||
overdue: boolean;
|
||||
priorityLabel: string;
|
||||
priorityClass: string;
|
||||
ownerInitials: string | null;
|
||||
riskCount: number;
|
||||
// Cockpit (M3): optionaler Bereichs-Badge auf der Karte.
|
||||
domainLabel?: string | null;
|
||||
};
|
||||
|
||||
export type KanbanColumn = {
|
||||
status: string;
|
||||
label: string;
|
||||
cards: KanbanCard[];
|
||||
};
|
||||
|
||||
/** Maßnahmen-Kanban (SPEC §4.4): Drag-and-Drop verschiebt den Status. */
|
||||
export function KanbanBoard({
|
||||
columns,
|
||||
canWrite,
|
||||
onMove,
|
||||
onReorder,
|
||||
detailBase = "/measures",
|
||||
}: {
|
||||
columns: KanbanColumn[];
|
||||
canWrite: boolean;
|
||||
// Server-Action: (itemId, newStatus)
|
||||
onMove: (itemId: string, status: string) => Promise<void>;
|
||||
// Cockpit (M3): optionale Persistenz der Reihenfolge einer Spalte (orderIdx).
|
||||
// Erhält die neue, vollständige ID-Reihenfolge der Zielspalte.
|
||||
onReorder?: (orderedIds: string[]) => Promise<void>;
|
||||
// Basis-Route für das Detail-Popup beim Klick auf eine Karte (?detail=<id>).
|
||||
detailBase?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
|
||||
);
|
||||
|
||||
// Karten-ID → Spalte (für Drop-auf-Karte-Auflösung).
|
||||
const statusOf = new Map<string, string>();
|
||||
for (const col of columns) for (const c of col.cards) statusOf.set(c.id, col.status);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
const activeId = String(active.id);
|
||||
const fromStatus = active.data.current?.status as string;
|
||||
|
||||
// Ziel bestimmen: Drop auf eine Karte (card:<id>) oder auf eine Spalte (<status>).
|
||||
const overId = String(over.id);
|
||||
const overCardId = overId.startsWith("card:") ? overId.slice(5) : null;
|
||||
const toStatus = overCardId ? statusOf.get(overCardId) ?? fromStatus : overId;
|
||||
|
||||
const statusChanged = fromStatus !== toStatus;
|
||||
if (statusChanged) startTransition(() => onMove(activeId, toStatus));
|
||||
|
||||
// Reihenfolge in der Zielspalte neu berechnen und persistieren (falls unterstützt).
|
||||
if (onReorder) {
|
||||
const target = columns.find((c) => c.status === toStatus);
|
||||
if (target) {
|
||||
const ids = target.cards.map((c) => c.id).filter((id) => id !== activeId);
|
||||
const insertAt = overCardId ? Math.max(0, ids.indexOf(overCardId)) : ids.length;
|
||||
ids.splice(insertAt, 0, activeId);
|
||||
const current = target.cards.map((c) => c.id);
|
||||
if (statusChanged || ids.join() !== current.join()) {
|
||||
startTransition(() => onReorder(ids));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{columns.map((col) => (
|
||||
<Column key={col.status} column={col} canWrite={canWrite} sortable={!!onReorder} onOpen={(id) => router.push(`${detailBase}?detail=${id}`)} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function Column({
|
||||
column,
|
||||
canWrite,
|
||||
sortable,
|
||||
onOpen,
|
||||
}: {
|
||||
column: KanbanColumn;
|
||||
canWrite: boolean;
|
||||
sortable: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id: column.status });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
"rounded-xl border bg-muted/60 p-2.5 transition-colors",
|
||||
isOver && "border-[var(--primary)] bg-[var(--band)]"
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between px-1">
|
||||
<span className="font-heading text-[12.5px] font-semibold">{column.label}</span>
|
||||
<span className="rounded-full bg-[var(--elevated)] px-2 py-0.5 text-[11px] font-bold text-muted-foreground">
|
||||
{column.cards.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{column.cards.map((card) => (
|
||||
<Card key={card.id} card={card} status={column.status} canWrite={canWrite} sortable={sortable} onOpen={onOpen} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
card,
|
||||
status,
|
||||
canWrite,
|
||||
sortable,
|
||||
onOpen,
|
||||
}: {
|
||||
card: KanbanCard;
|
||||
status: string;
|
||||
canWrite: boolean;
|
||||
sortable: boolean;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: card.id,
|
||||
data: { status },
|
||||
disabled: !canWrite,
|
||||
});
|
||||
// Cockpit (M3): Karte zugleich Drop-Ziel → Einsortieren vor dieser Karte.
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({ id: `card:${card.id}`, disabled: !sortable });
|
||||
|
||||
const setRefs = (node: HTMLElement | null) => {
|
||||
setNodeRef(node);
|
||||
setDropRef(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setRefs}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
onClick={() => !isDragging && onOpen(card.id)}
|
||||
style={
|
||||
transform
|
||||
? { transform: `translate(${transform.x}px, ${transform.y}px)` }
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"shadow-card cursor-pointer rounded-lg border bg-card p-3 text-sm",
|
||||
canWrite && "cursor-grab active:cursor-grabbing",
|
||||
isDragging && "z-50 opacity-80 shadow-lg",
|
||||
sortable && isOver && !isDragging && "border-[var(--primary)]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-bold text-muted-foreground">{card.refLabel}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-full px-2 py-[2px] text-[10.5px] font-bold",
|
||||
card.priorityClass
|
||||
)}
|
||||
>
|
||||
{card.priorityLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-semibold leading-snug">{card.title}</p>
|
||||
<div className="mt-2 flex items-center justify-between text-[11.5px] text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{card.domainLabel && (
|
||||
<span className="rounded-full bg-[var(--sidebar-accent)] px-2 py-[1px] text-[10px] font-semibold text-[var(--primary)]">
|
||||
{card.domainLabel}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn(card.overdue && "font-bold text-[var(--risk)]")}>
|
||||
{card.dueLabel ?? "—"}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{card.riskCount > 0 && <span title="Verknüpfte Risiken">⚠ {card.riskCount}</span>}
|
||||
{card.ownerInitials && (
|
||||
<span className="grid size-6 place-items-center rounded-full bg-[var(--sidebar-accent)] text-[10px] font-bold text-[var(--primary)]">
|
||||
{card.ownerInitials}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { Mail } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { sendTestMail, type TestMailState } from "@/server/actions/mail";
|
||||
|
||||
/**
|
||||
* SEC1 §9 — Betriebsanzeige der Mail-Strecke + Testversand.
|
||||
*
|
||||
* Der Testversand geht immer an die eigene Adresse des angemeldeten
|
||||
* Plattform-Admins (siehe src/server/actions/mail.ts) — deshalb gibt es hier
|
||||
* bewusst kein Empfängerfeld.
|
||||
*/
|
||||
|
||||
type MailStatus = {
|
||||
configured: boolean;
|
||||
reason?: string;
|
||||
host?: string;
|
||||
from?: string;
|
||||
queue: "bullmq" | "inline";
|
||||
recent: {
|
||||
id: string;
|
||||
to: string;
|
||||
template: string;
|
||||
status: string;
|
||||
error: string | null;
|
||||
createdAt: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
const STATUS_TONE: Record<string, "ok" | "warn" | "risk" | "mut"> = {
|
||||
sent: "ok",
|
||||
pending: "warn",
|
||||
failed: "risk",
|
||||
bounced: "risk",
|
||||
suppressed: "mut",
|
||||
};
|
||||
|
||||
export function MailStatusPanel({ status }: { status: MailStatus }) {
|
||||
const [state, action, pending] = useActionState<TestMailState, FormData>(
|
||||
async () => sendTestMail(),
|
||||
{ status: "idle" },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="shadow-card mt-5 rounded-xl border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-heading text-sm font-semibold">E-Mail-Versand</p>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
{status.configured
|
||||
? `SMTP ${status.host} · Absender ${status.from} · ${
|
||||
status.queue === "bullmq"
|
||||
? "asynchron über die Queue (Worker)"
|
||||
: "inline (kein Redis konfiguriert — kein Retry)"
|
||||
}`
|
||||
: (status.reason ?? "SMTP ist nicht konfiguriert.")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Pill tone={status.configured ? "ok" : "warn"}>
|
||||
{status.configured ? "Konfiguriert" : "Nicht konfiguriert"}
|
||||
</Pill>
|
||||
<form action={action}>
|
||||
<Button type="submit" variant="outline" size="sm" disabled={!status.configured || pending}>
|
||||
<Mail className="size-4" /> {pending ? "Sende…" : "Test-Mail senden"}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.status !== "idle" && (
|
||||
<p
|
||||
role="status"
|
||||
className={`mt-3 rounded-lg px-3 py-2 text-[13px] ${
|
||||
state.status === "ok"
|
||||
? "bg-[rgba(57,192,127,0.14)] text-[var(--ok)]"
|
||||
: "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]"
|
||||
}`}
|
||||
>
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{status.recent.length > 0 && (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr className="text-left">
|
||||
<th className="py-1.5 pr-3 font-semibold">Zeitpunkt</th>
|
||||
<th className="py-1.5 pr-3 font-semibold">Empfänger</th>
|
||||
<th className="py-1.5 pr-3 font-semibold">Template</th>
|
||||
<th className="py-1.5 pr-3 font-semibold">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{status.recent.map((row) => (
|
||||
<tr key={row.id} className="border-t border-[var(--panel-brd)]">
|
||||
<td className="py-1.5 pr-3 whitespace-nowrap">
|
||||
{new Intl.DateTimeFormat("de-DE", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short",
|
||||
}).format(new Date(row.createdAt))}
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{row.to}</td>
|
||||
<td className="py-1.5 pr-3 font-mono text-[11.5px]">{row.template}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<Pill tone={STATUS_TONE[row.status] ?? "mut"}>{row.status}</Pill>
|
||||
{row.error && (
|
||||
<span className="ml-2 text-[11px] text-muted-foreground" title={row.error}>
|
||||
{row.error.slice(0, 60)}
|
||||
{row.error.length > 60 ? "…" : ""}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { createMeasure, deleteMeasure, updateMeasure } from "@/server/actions/measures";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import { measureRef, MEASURE_PRIORITY_TONE, MEASURE_STATUS_TONE } from "@/lib/measure";
|
||||
|
||||
export type MeasureWithDetail = Prisma.MeasureGetPayload<{
|
||||
include: {
|
||||
owner: { select: { id: true; name: true } };
|
||||
riskMeasures: {
|
||||
include: { risk: { select: { id: true; refNo: true; title: true; score: true } } };
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const STATUSES = ["OPEN", "IN_PROGRESS", "DONE"] as const;
|
||||
const PRIORITIES = ["LOW", "MEDIUM", "HIGH"] as const;
|
||||
|
||||
/** Read-only-Maßnahmen-Detail als Popup. */
|
||||
export async function MeasureDetailModal({
|
||||
measure,
|
||||
canWrite,
|
||||
}: {
|
||||
measure: MeasureWithDetail;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("measures");
|
||||
const tr = await getTranslations("risks");
|
||||
const tStatus = await getTranslations("measureStatus");
|
||||
const tPrio = await getTranslations("measurePriority");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tc = await getTranslations("common");
|
||||
const format = await getFormatter();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("detailHeading", { ref: measureRef(measure.refNo), name: measure.title })}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone={MEASURE_PRIORITY_TONE[measure.priority]}>{tPrio(measure.priority)}</Pill>
|
||||
<Pill tone={MEASURE_STATUS_TONE[measure.status]}>{tStatus(measure.status)}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref="/measures"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/measures?edit=${measure.id}`} />}
|
||||
>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/measures" />}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<b>{measure.title}</b>
|
||||
{measure.description && (
|
||||
<p className="mt-1.5 text-[12.5px] leading-relaxed">{measure.description}</p>
|
||||
)}
|
||||
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{measure.owner?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("dueDate")}</dt>
|
||||
<dd>
|
||||
{measure.dueDate
|
||||
? format.dateTime(measure.dueDate, { dateStyle: "medium" })
|
||||
: tc("none")}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{t("linkedRisks")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("linkedRisksNote")}</span>
|
||||
</div>
|
||||
{measure.riskMeasures.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("noRisks")}</p>
|
||||
)}
|
||||
<ul className="space-y-1.5 text-sm">
|
||||
{measure.riskMeasures.map((rm) => (
|
||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/risks?detail=${rm.risk.id}`} className="font-bold hover:underline">
|
||||
{riskRef(rm.risk.refNo)}
|
||||
</Link>
|
||||
<Link href={`/risks?detail=${rm.risk.id}`} className="hover:underline">
|
||||
{rm.risk.title}
|
||||
</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(rm.risk.score)]}>
|
||||
{rm.risk.score} · {tLevel(riskLevel(rm.risk.score))}
|
||||
</Pill>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tr("reduction")}: −{format.number(rm.reductionLikelihood, { maximumFractionDigits: 2 })} /
|
||||
−{format.number(rm.reductionImpact, { maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularfelder für Anlegen/Bearbeiten. */
|
||||
async function MeasureFields({
|
||||
measure,
|
||||
users,
|
||||
}: {
|
||||
measure?: MeasureWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("measures");
|
||||
const tStatus = await getTranslations("measureStatus");
|
||||
const tPrio = await getTranslations("measurePriority");
|
||||
const tc = await getTranslations("common");
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="title">{t("titleField")}</Label>
|
||||
<Input id="title" name="title" required defaultValue={measure?.title} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={measure?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="status">{t("status")}</Label>
|
||||
<select id="status" name="status" defaultValue={measure?.status ?? "OPEN"} className={`${selectClass} mt-1`}>
|
||||
{STATUSES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tStatus(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="priority">{t("priority")}</Label>
|
||||
<select
|
||||
id="priority"
|
||||
name="priority"
|
||||
defaultValue={measure?.priority ?? "MEDIUM"}
|
||||
className={`${selectClass} mt-1`}
|
||||
>
|
||||
{PRIORITIES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tPrio(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select id="ownerId" name="ownerId" defaultValue={measure?.ownerId ?? ""} className={`${selectClass} mt-1`}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dueDate">{t("dueDate")}</Label>
|
||||
<Input
|
||||
id="dueDate"
|
||||
name="dueDate"
|
||||
type="date"
|
||||
defaultValue={measure?.dueDate ? measure.dueDate.toISOString().slice(0, 10) : ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten als Popup. */
|
||||
export async function MeasureEditModal({
|
||||
measure,
|
||||
users,
|
||||
}: {
|
||||
measure: MeasureWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("measures");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={`${measureRef(measure.refNo)} · ${measure.title}`}
|
||||
closeHref={`/measures?detail=${measure.id}`}
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
<div className="p-5">
|
||||
<form
|
||||
key={measure.updatedAt.toISOString()}
|
||||
action={updateMeasure.bind(null, measure.id)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<MeasureFields measure={measure} users={users} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/measures?detail=${measure.id}`} />}
|
||||
>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-5 border-t pt-4">
|
||||
<form action={deleteMeasure.bind(null, measure.id)}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anlegen als Popup. */
|
||||
export async function MeasureCreateModal({
|
||||
users,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("measures");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/measures" closeLabel={t("close")}>
|
||||
<form action={createMeasure} className="space-y-4 p-5">
|
||||
<MeasureFields users={[...users]} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/measures" />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Basisbausteine im Look des UI-Mockups (docs/ISMS-Prototyp-GEFIM.html —
|
||||
* historisches Projektartefakt, der Dateiname bleibt bewusst unverändert):
|
||||
* PageHead (Crumb/Titel/Sub + Aktionen), KPI-Kapsel, C/I/A-Quadrate,
|
||||
* Tag-Chip, Owner-Chip, Status-Pille.
|
||||
*/
|
||||
|
||||
export function PageHead({
|
||||
crumb,
|
||||
title,
|
||||
sub,
|
||||
actions,
|
||||
}: {
|
||||
crumb: string;
|
||||
title: string;
|
||||
sub?: string;
|
||||
actions?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4.5 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{crumb}</div>
|
||||
<h1 className="text-[22px]">{title}</h1>
|
||||
{sub && <div className="mt-1 text-[13px] text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
{actions && <div className="flex gap-2">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function KpiCard({
|
||||
label,
|
||||
value,
|
||||
trend,
|
||||
trendColor = "muted",
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
trend?: string;
|
||||
trendColor?: "muted" | "risk" | "warn" | "ok";
|
||||
}) {
|
||||
const trendColors = {
|
||||
muted: "text-muted-foreground",
|
||||
risk: "text-[var(--risk)]",
|
||||
warn: "text-[var(--warn)]",
|
||||
ok: "text-[var(--ok)]",
|
||||
};
|
||||
return (
|
||||
<div className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="text-[12.5px] font-semibold text-muted-foreground">{label}</div>
|
||||
<div className="mt-1.5 mb-0.5 font-heading text-3xl font-bold leading-none">{value}</div>
|
||||
{trend && <div className={cn("mt-1 text-xs font-semibold", trendColors[trendColor])}>{trend}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Segmentbalken-Farben je Stufe 1–4 als Ampel: Grün → Gelb → Orange → Rot
|
||||
const CIA_BAR_COLORS: Record<number, string> = {
|
||||
1: "bg-[#2e9e6b]",
|
||||
2: "bg-[#e5b000]",
|
||||
3: "bg-[#e07d2e]",
|
||||
4: "bg-[#d64c4c]",
|
||||
};
|
||||
|
||||
/** Ein Schutzziel als 4er-Segmentbalken, gefüllt bis `level`. */
|
||||
function CiaSegment({ level, label }: { level: number; label?: string }) {
|
||||
const color = CIA_BAR_COLORS[level] ?? CIA_BAR_COLORS[1];
|
||||
return (
|
||||
<span className="inline-flex flex-col items-center gap-1">
|
||||
<span className="flex gap-[2px]">
|
||||
{[1, 2, 3, 4].map((seg) => (
|
||||
<span
|
||||
key={seg}
|
||||
className={cn(
|
||||
"h-4 w-[7px] rounded-[2px]",
|
||||
seg <= level ? color : "bg-[rgba(120,135,180,0.2)]"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
{label && (
|
||||
<small className="text-[9.5px] font-bold tracking-[.04em] text-muted-foreground">
|
||||
{label}
|
||||
</small>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schutzbedarf / Schadenshöhe als drei Segmentbalken (C/I/A). Mit `labels`
|
||||
* werden VER/INT/VFB unter den Balken gezeigt (Detailkarten); ohne für
|
||||
* kompakte Tabellenzellen.
|
||||
*/
|
||||
export function CiaBadge({
|
||||
c,
|
||||
i,
|
||||
a,
|
||||
labels = false,
|
||||
}: {
|
||||
c: number;
|
||||
i: number;
|
||||
a: number;
|
||||
labels?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5" title={`C${c} · I${i} · A${a}`}>
|
||||
<CiaSegment level={c} label={labels ? "VER" : undefined} />
|
||||
<CiaSegment level={i} label={labels ? "INT" : undefined} />
|
||||
<CiaSegment level={a} label={labels ? "VFB" : undefined} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** VER/INT/VFB-Beschriftung passend zur Balkenbreite — einmal pro Tabelle als Überschrift. */
|
||||
export function CiaLegend() {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2.5 text-[9.5px] font-bold tracking-[.04em] text-muted-foreground">
|
||||
{["VER", "INT", "VFB"].map((l) => (
|
||||
<span key={l} className="w-[34px] text-center">
|
||||
{l}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Tag({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="inline-block rounded-md bg-[rgba(139,147,173,0.14)] px-2 py-0.5 text-[11px] font-bold text-[#b7bdd0]">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function OwnerChip({ name, noOwnerLabel }: { name?: string | null; noOwnerLabel: string }) {
|
||||
if (!name) return <Pill tone="risk">{noOwnerLabel}</Pill>;
|
||||
const initials = name
|
||||
.split(/\s+/)
|
||||
.map((p) => p[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
return (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="grid size-6.5 place-items-center rounded-full bg-[var(--sidebar-accent)] font-heading text-[11px] font-bold text-[var(--primary)]">
|
||||
{initials}
|
||||
</span>
|
||||
{name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Status-Chips: ~16–20 % Alpha der Statusfarbe auf Dunkel (Referenz-Vorgabe)
|
||||
const PILL_TONES = {
|
||||
ok: "bg-[rgba(57,192,127,0.16)] text-[var(--ok)]",
|
||||
warn: "bg-[rgba(240,173,78,0.16)] text-[var(--warn)]",
|
||||
orange: "bg-[rgba(240,140,60,0.16)] text-[#f0a35a]",
|
||||
risk: "bg-[rgba(255,107,107,0.16)] text-[var(--risk)]",
|
||||
info: "bg-[rgba(90,169,230,0.16)] text-[var(--info)]",
|
||||
mut: "bg-[rgba(139,147,173,0.16)] text-muted-foreground",
|
||||
violet: "bg-[rgba(125,111,214,0.2)] text-[#c3bdec]",
|
||||
} as const;
|
||||
|
||||
export function Pill({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: keyof typeof PILL_TONES;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-[3px] text-[11.5px] font-bold whitespace-nowrap",
|
||||
PILL_TONES[tone]
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kritikalität 1–4 als Pille in Mockup-Farben (info/warn/risk). */
|
||||
export function CriticalityPill({ level, label }: { level: number; label: string }) {
|
||||
const tone = level >= 4 ? "risk" : level === 3 ? "warn" : level === 2 ? "info" : "ok";
|
||||
return <Pill tone={tone}>{label}</Pill>;
|
||||
}
|
||||
|
||||
export function SectTitle({ title, sub }: { title: string; sub?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="font-heading text-[15px] font-semibold">{title}</div>
|
||||
{sub && <div className="mt-0.5 text-[12.5px] text-muted-foreground">{sub}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import { X } from "lucide-react";
|
||||
import { SectTitle } from "@/components/mockup-ui";
|
||||
|
||||
/**
|
||||
* Serverseitig gerendertes, schließbares Popup (Overlay). Öffnen/Schließen
|
||||
* läuft über searchParams der Listen-Seite (?detail=/?edit=/?new=1) —
|
||||
* kein Client-State, Browser-Zurück schließt das Popup ebenfalls.
|
||||
*/
|
||||
export function Modal({
|
||||
title,
|
||||
sub,
|
||||
headerExtra,
|
||||
closeHref,
|
||||
closeLabel,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
title: string;
|
||||
sub?: string;
|
||||
headerExtra?: React.ReactNode;
|
||||
closeHref: string;
|
||||
closeLabel: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]">
|
||||
<div className="shadow-card my-auto w-full max-w-4xl rounded-2xl border bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
||||
<SectTitle title={title} sub={sub} />
|
||||
<div className="flex items-center gap-3">
|
||||
{headerExtra}
|
||||
<Link
|
||||
href={closeHref}
|
||||
title={closeLabel}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="size-4.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
{footer && <div className="flex justify-end gap-2 border-t p-5">{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function NavLink({
|
||||
href,
|
||||
match,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
match?: string[];
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = (match ?? [href]).some(
|
||||
(m) => pathname === m || pathname.startsWith(m + "/")
|
||||
);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13.5px] font-semibold transition-colors",
|
||||
active
|
||||
? "bg-sidebar-accent text-sidebar-accent-foreground [&_svg]:opacity-100"
|
||||
: "text-sidebar-foreground hover:bg-secondary hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { hasPermission } from "@/server/rbac";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { eligibleValidators, latestReviewTask } from "@/server/object-review";
|
||||
import { REVIEW_LABEL, REVIEW_TONE, reviewState } from "@/lib/object-review";
|
||||
import { approveTask, rejectTask } from "@/server/actions/tasks";
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
/**
|
||||
* Generisches Objekt-Review (Story A3-1). Zeigt den aus der jüngsten Review-Aufgabe
|
||||
* abgeleiteten Status eines beliebigen Objekts und bietet — je nach Rolle —
|
||||
* Einreichen zur Validierung (Vier-Augen) bzw. Validieren/Zurückweisen an.
|
||||
*
|
||||
* `submit` ist die objekt-modulspezifische Einreich-Action (z. B. `submitRiskForReview`
|
||||
* gebunden an die Objekt-ID); sie erwartet `approverId` und optional `note` im FormData.
|
||||
*/
|
||||
export async function ObjectReview({
|
||||
entityType,
|
||||
entityId,
|
||||
title,
|
||||
submit,
|
||||
canSubmit,
|
||||
}: {
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
entityRef?: string | null;
|
||||
title: string;
|
||||
submit: (formData: FormData) => Promise<void>;
|
||||
canSubmit: boolean;
|
||||
}) {
|
||||
const session = await requireSession();
|
||||
const db = dbForTenant(session.user.tenantId);
|
||||
const task = await latestReviewTask(db, entityType, entityId);
|
||||
const state = reviewState(task);
|
||||
|
||||
const isOpen = task?.status === "OPEN";
|
||||
const isAssignee = task?.assigneeId === session.user.id;
|
||||
const canValidate = hasPermission(session, "validate_objects");
|
||||
const validators = !isOpen && canSubmit ? await eligibleValidators(db, session.user.id) : [];
|
||||
|
||||
const names = new Map<string, string>();
|
||||
const ids = [task?.assigneeId, task?.createdById, task?.resolvedById].filter(Boolean) as string[];
|
||||
if (ids.length) {
|
||||
(await db.user.findMany({ where: { id: { in: ids } }, select: { id: true, name: true } })).forEach((u) => names.set(u.id, u.name));
|
||||
}
|
||||
const lastReviewComment = [...(task?.comments ?? [])].reverse().find((c) => c.kind === "reject" || c.kind === "approve");
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-heading text-sm font-semibold">Validierung</span>
|
||||
<Pill tone={REVIEW_TONE[state]}>{REVIEW_LABEL[state]}</Pill>
|
||||
</div>
|
||||
{task && <span className="text-[11.5px] text-muted-foreground">nur „Bestätigt“ zählt als abgesichert</span>}
|
||||
</div>
|
||||
|
||||
{/* Laufende Validierung */}
|
||||
{isOpen && (
|
||||
<div className="mt-3 text-[12.5px]">
|
||||
<p className="text-muted-foreground">Zur Validierung bei <b>{names.get(task!.assigneeId ?? "") ?? "—"}</b>{task!.createdById ? <> · eingereicht von {names.get(task!.createdById) ?? "—"}</> : null}.</p>
|
||||
{isAssignee && canValidate ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<form action={approveTask.bind(null, task!.id)}>
|
||||
<Button type="submit" size="sm">Validieren</Button>
|
||||
</form>
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center rounded-md border border-input px-2.5 text-sm font-medium select-none hover:bg-muted [&::-webkit-details-marker]:hidden">Zurückweisen</summary>
|
||||
<form action={rejectTask.bind(null, task!.id)} className="shadow-card absolute left-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3">
|
||||
<Textarea name="note" rows={3} placeholder="Grund der Rückweisung" required />
|
||||
<Button type="submit" variant="secondary" size="sm">Zurückweisen</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-muted-foreground">Wartet auf Entscheidung des Validators.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abgeschlossenes Review + Begründung */}
|
||||
{!isOpen && lastReviewComment && (
|
||||
<p className="mt-2 text-[12.5px] text-muted-foreground">
|
||||
{state === "bestaetigt" ? "Bestätigt" : "Zurückgewiesen"} von {names.get(task!.resolvedById ?? "") ?? "—"}: „{lastReviewComment.body}“
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Einreichen zur Validierung */}
|
||||
{!isOpen && canSubmit && (
|
||||
validators.length === 0 ? (
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">Kein aktiver Validator (Recht „validate_objects“) verfügbar.</p>
|
||||
) : (
|
||||
<form action={submit} className="mt-3 space-y-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{state === "zurueckgewiesen" ? "Nach Nacharbeit erneut zur Validierung einreichen:" : "Zur Validierung einreichen (Vier-Augen):"}
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,16rem)_1fr] sm:items-center">
|
||||
<select name="approverId" required className={inputCls} defaultValue="">
|
||||
<option value="" disabled>Validator wählen…</option>
|
||||
{validators.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
<input name="note" placeholder="Notiz (optional)" className={inputCls} />
|
||||
</div>
|
||||
<Button type="submit" size="sm" title={title}>Zur Validierung einreichen</Button>
|
||||
</form>
|
||||
)
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { startAuthentication } from "@simplewebauthn/browser";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { beginPasskeyLogin } from "@/server/actions/webauthn";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* SEC3-b: Passkey-Login. Holt Challenge/Optionen (Server-Action), führt die Browser-
|
||||
* Zeremonie aus und meldet die Assertion beim „passkey"-Provider an. Discoverable
|
||||
* Credentials → kein E-Mail/Passwort nötig.
|
||||
*/
|
||||
export function PasskeyLoginButton({ callbackUrl }: { callbackUrl: string }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function login() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await beginPasskeyLogin();
|
||||
const assertion = await startAuthentication(options);
|
||||
const res = await signIn("passkey", { response: JSON.stringify(assertion), redirect: false, callbackUrl });
|
||||
if (res?.error) setError("Passkey-Anmeldung fehlgeschlagen.");
|
||||
else window.location.href = callbackUrl;
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error && e.name === "NotAllowedError"
|
||||
? "Anmeldung abgebrochen."
|
||||
: "Passkey nicht verfügbar oder Anmeldung fehlgeschlagen.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<Button type="button" variant="outline" className="w-full" onClick={login} disabled={busy}>
|
||||
{busy ? "…" : "Mit Passkey anmelden"}
|
||||
</Button>
|
||||
{error && <p role="alert" className="mt-2 text-center text-[12px] text-[var(--risk)]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { startRegistration } from "@simplewebauthn/browser";
|
||||
import { beginPasskeyRegistration, finishPasskeyRegistration, removePasskey } from "@/server/actions/webauthn";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
export interface PasskeyItem {
|
||||
id: string;
|
||||
deviceName: string | null;
|
||||
createdLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEC3-b: Passkeys registrieren & verwalten. Die Registrierungs-Zeremonie läuft im
|
||||
* Browser (startRegistration); Options-/Verify-Schritt über Server-Actions.
|
||||
*/
|
||||
export function PasskeyManager({ credentials }: { credentials: PasskeyItem[] }) {
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [, startRemoval] = useTransition();
|
||||
|
||||
async function register() {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const options = await beginPasskeyRegistration();
|
||||
const response = await startRegistration(options);
|
||||
await finishPasskeyRegistration(response, name);
|
||||
setName("");
|
||||
} catch (e) {
|
||||
// Abbruch durch den Nutzer (NotAllowedError) oder Fehler in der Zeremonie.
|
||||
setError(e instanceof Error && e.name === "NotAllowedError" ? "Registrierung abgebrochen." : e instanceof Error ? e.message : "Passkey-Registrierung fehlgeschlagen.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{credentials.length > 0 && (
|
||||
<ul className="space-y-1.5">
|
||||
{credentials.map((c) => (
|
||||
<li key={c.id} className="flex items-center justify-between gap-2 rounded-lg border bg-card px-3 py-1.5 text-[12.5px]">
|
||||
<span>{c.deviceName || "Passkey"} <span className="text-muted-foreground">· {c.createdLabel}</span></span>
|
||||
<form action={() => startRemoval(() => removePasskey(c.id))}>
|
||||
<Button type="submit" variant="ghost" size="sm">Entfernen</Button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Gerätename (optional)" className="h-8 w-52" />
|
||||
<Button type="button" size="sm" variant="outline" onClick={register} disabled={busy}>{busy ? "…" : "Passkey hinzufügen"}</Button>
|
||||
</div>
|
||||
{error && <p role="alert" className="text-[12px] text-[var(--risk)]">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { createPlatformAdmin, resetPlatformAdminPassword, type AdminActionState } from "@/server/actions/platform-admins";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const initial: AdminActionState = { status: "idle" };
|
||||
const selectCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
function Feedback({ state }: { state: AdminActionState }) {
|
||||
if (state.status === "error") return <p role="alert" className="text-[12px] text-[var(--risk)]">{state.message}</p>;
|
||||
if (state.status === "done") return <p className="text-[12px] text-[var(--ok)]">{state.message}</p>;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** SEC4: neuen Plattform-Admin anlegen (Voll-Admin + Step-up). */
|
||||
export function CreatePlatformAdminForm({ mfaEnrolled }: { mfaEnrolled: boolean }) {
|
||||
const [state, action, pending] = useActionState(createPlatformAdmin, initial);
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="pa-email">E-Mail</Label>
|
||||
<Input id="pa-email" name="email" type="email" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pa-name">Name</Label>
|
||||
<Input id="pa-name" name="name" required className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pa-role">Rolle</Label>
|
||||
<select id="pa-role" name="role" defaultValue="readonly" className={`${selectCls} mt-1`}>
|
||||
<option value="readonly">Read-only (nur lesen)</option>
|
||||
<option value="full">Voll-Admin (darf verwalten)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="pa-pw">Initial-Passwort</Label>
|
||||
<Input id="pa-pw" name="password" type="password" required className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
{mfaEnrolled && (
|
||||
<div>
|
||||
<Label htmlFor="pa-token">Ihr aktueller MFA-Code (Step-up)</Label>
|
||||
<Input id="pa-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" className="mt-1 w-40" required />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="submit" size="sm" disabled={pending}>{pending ? "…" : "Admin anlegen"}</Button>
|
||||
<Feedback state={state} />
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** SEC4: Passwort eines Admins zurücksetzen (Step-up). */
|
||||
export function ResetPlatformAdminPasswordForm({ adminId, mfaEnrolled }: { adminId: string; mfaEnrolled: boolean }) {
|
||||
const [state, action, pending] = useActionState(resetPlatformAdminPassword.bind(null, adminId), initial);
|
||||
return (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-[12px] text-muted-foreground">Passwort zurücksetzen</summary>
|
||||
<form action={action} className="mt-2 flex flex-wrap items-end gap-2">
|
||||
<Input name="password" type="password" placeholder="Neues Passwort" className="h-8 w-48" required />
|
||||
{mfaEnrolled && <Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="MFA-Code" className="h-8 w-28" required />}
|
||||
<Button type="submit" size="sm" variant="outline" disabled={pending}>{pending ? "…" : "Zurücksetzen"}</Button>
|
||||
<Feedback state={state} />
|
||||
</form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { confirmMfaEnrollment, type EnrollState } from "@/server/actions/platform";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const initial: EnrollState = { status: "idle" };
|
||||
|
||||
export function PlatformEnrollForm() {
|
||||
const [state, action, pending] = useActionState(confirmMfaEnrollment, initial);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-[rgba(46,204,113,0.14)] px-3 py-2 text-sm text-[var(--ok)]">
|
||||
MFA ist aktiv. Bewahren Sie die folgenden Recovery-Codes sicher auf — sie werden
|
||||
<strong> nur jetzt</strong> angezeigt und ermöglichen den Zugang, falls der Authenticator verloren geht.
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{state.recoveryCodes.map((c) => (
|
||||
<li key={c} className="rounded border bg-card px-3 py-1.5 text-center tracking-wider">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button nativeButton={false} render={<Link href="/admin" />} className="w-full">
|
||||
Codes gesichert — zur Admin-Konsole
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="token">6-stelliger Code aus der Authenticator-App</Label>
|
||||
<Input
|
||||
id="token"
|
||||
name="token"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
placeholder="123456"
|
||||
required
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||
{state.message}
|
||||
</p>
|
||||
)}
|
||||
<Button type="submit" disabled={pending} className="w-full">
|
||||
{pending ? "Prüfe…" : "MFA aktivieren"}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Send } from "lucide-react";
|
||||
import { Pill, Tag } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { compareControl } from "@/lib/control-titles";
|
||||
import { DOMAIN_LABELS, DOMAIN_ORDER, DOMAIN_FUNCTION, defaultDomainForControl } from "@/lib/control-domain";
|
||||
import { submitDeriveDomains, setPolicyDomain, submitForApproval } from "@/server/actions/policies";
|
||||
import type { TenantDb } from "@/server/db";
|
||||
import type { Domain } from "@prisma/client";
|
||||
|
||||
const TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie",
|
||||
RICHTLINIE: "Richtlinie",
|
||||
VERFAHREN: "Verfahren",
|
||||
REGISTER: "Register",
|
||||
HANDBUCH: "Handbuch",
|
||||
EIGENES: "Eigenes",
|
||||
};
|
||||
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
||||
FREIGEGEBEN: "ok",
|
||||
IN_FREIGABE: "info",
|
||||
ENTWURF: "warn",
|
||||
ARCHIVIERT: "mut",
|
||||
};
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
FREIGEGEBEN: "Freigegeben",
|
||||
IN_FREIGABE: "In Freigabe",
|
||||
ENTWURF: "Entwurf",
|
||||
ARCHIVIERT: "Archiviert",
|
||||
};
|
||||
|
||||
/** Dokumenttypen, die aus der Ansicht heraus bearbeitet/eingereicht werden können. */
|
||||
const EDITABLE_TYPES = new Set(["LEITLINIE", "RICHTLINIE", "VERFAHREN"]);
|
||||
|
||||
/**
|
||||
* Geteilte „Richtlinien nach Fachbereich"-Ansicht.
|
||||
*
|
||||
* Rendert die Richtlinien gruppiert nach Fachbereich (Domain) mit
|
||||
* Verantwortlichem je Bereich sowie — bei Schreibrecht (`canWrite`) — der
|
||||
* Fachbereichs-Zuordnung (`setPolicyDomain`), „Bearbeiten" (Editor-Link),
|
||||
* „Zur Prüfung geben" (`submitForApproval`, nur ENTWURF) und der
|
||||
* Domain-Ableitung (`submitDeriveDomains`).
|
||||
*
|
||||
* Wird sowohl vom eigenständigen Richtlinien-Modul (`/policies?view=domains`)
|
||||
* als auch vom Onboarding-Schritt „Leitlinie & Richtlinien" genutzt — die
|
||||
* Fachbereichs-Logik liegt damit an genau einer Stelle. Die Komponente lädt
|
||||
* ihre Daten selbst über den mandantengebundenen `db`. `currentUserId` schließt
|
||||
* den anfragenden Nutzer aus der Freigeber-Auswahl aus (Vier-Augen). Ohne
|
||||
* `canWrite` wird die Ansicht rein lesend gerendert (Editier-Spalten entfallen).
|
||||
*
|
||||
* @param compact Kontext-Flag (z. B. Onboarding): blendet den erklärenden
|
||||
* Intro-Hinweis aus, die Ableiten-Aktion bleibt erhalten.
|
||||
*/
|
||||
export async function PolicyDomainView({
|
||||
db,
|
||||
currentUserId,
|
||||
canWrite,
|
||||
compact = false,
|
||||
returnTo,
|
||||
}: {
|
||||
db: TenantDb;
|
||||
currentUserId: string;
|
||||
canWrite: boolean;
|
||||
compact?: boolean;
|
||||
/** Interner Pfad, zu dem „Fachbereiche ableiten"/„Zur Prüfung geben" zurückkehren
|
||||
* (z. B. "/onboarding?step=policy", damit man im Wizard bleibt). Sonst /policies-Default. */
|
||||
returnTo?: string;
|
||||
}) {
|
||||
const t = await getTranslations("policies");
|
||||
|
||||
const [docs, requirements, assignments, approverUsers] = await Promise.all([
|
||||
db.policyDocument.findMany({ where: { archivedAt: null }, orderBy: { orderIdx: "asc" } }),
|
||||
db.policyRequirement.findMany({
|
||||
where: { archivedAt: null },
|
||||
select: { control: true, policyCode: true, vaCodes: true },
|
||||
}),
|
||||
db.projectFunctionAssignment.findMany({ select: { functionKey: true, userId: true, domain: true } }),
|
||||
db.user.findMany({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
id: { not: currentUserId },
|
||||
userRoles: { some: { role: { rolePermissions: { some: { permission: { key: "policy:approve" } } } } } },
|
||||
},
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: "asc" },
|
||||
}),
|
||||
]);
|
||||
|
||||
// Zuständigkeit je Bereich: explizite Bereichszuordnung, sonst Default-Funktion.
|
||||
const userIds = [...new Set(assignments.map((a) => a.userId).filter(Boolean) as string[])];
|
||||
const respUsers = userIds.length
|
||||
? await db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
const userName = (id: string | null) => respUsers.find((u) => u.id === id)?.name ?? null;
|
||||
|
||||
const primaryControl = (code: string): string | null => {
|
||||
const own = requirements.filter((r) => r.policyCode === code);
|
||||
const list = own.length ? own : requirements.filter((r) => r.vaCodes.includes(code));
|
||||
if (!list.length) return null;
|
||||
return [...list].map((r) => r.control).sort(compareControl)[0] ?? null;
|
||||
};
|
||||
const domainOf = (d: (typeof docs)[number]): Domain | null => {
|
||||
if (d.domain) return d.domain;
|
||||
const pc = primaryControl(d.code);
|
||||
return pc ? defaultDomainForControl(pc) : null;
|
||||
};
|
||||
const responsibleFor = (domain: Domain): string | null => {
|
||||
const byDomain = assignments.find((a) => a.domain === domain && a.userId);
|
||||
if (byDomain) return userName(byDomain.userId);
|
||||
const fk = DOMAIN_FUNCTION[domain];
|
||||
const byFn = fk ? assignments.find((a) => a.functionKey === fk && a.userId) : undefined;
|
||||
return byFn ? userName(byFn.userId) : null;
|
||||
};
|
||||
|
||||
// Gruppieren (aktive Bibliothek). Reihenfolge: DOMAIN_ORDER, dann „ohne Fachbereich".
|
||||
const groups = new Map<Domain | "none", typeof docs>();
|
||||
for (const key of DOMAIN_ORDER) groups.set(key, []);
|
||||
groups.set("none", []);
|
||||
for (const d of docs) {
|
||||
const key = domainOf(d) ?? "none";
|
||||
groups.get(key)!.push(d);
|
||||
}
|
||||
const orderedKeys: (Domain | "none")[] = [...DOMAIN_ORDER, "none"];
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!compact || canWrite) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
{compact ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Richtlinien nach Fachbereich (aus dem primären Control abgeleitet, manuell überschreibbar).
|
||||
</p>
|
||||
)}
|
||||
{canWrite && (
|
||||
<form action={submitDeriveDomains}>
|
||||
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
|
||||
<Button type="submit" variant="outline" size="sm">{t("deriveDomains")}</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
{orderedKeys.map((key) => {
|
||||
const list = groups.get(key)!;
|
||||
if (!list.length) return null;
|
||||
const isNone = key === "none";
|
||||
const resp = isNone ? null : responsibleFor(key);
|
||||
const fk = isNone ? null : DOMAIN_FUNCTION[key];
|
||||
return (
|
||||
<div key={key} className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-[var(--surface-soft)] px-4 py-3">
|
||||
<p className="font-heading text-sm font-bold">
|
||||
{isNone ? t("domainNone") : DOMAIN_LABELS[key]}{" "}
|
||||
<span className="text-[11px] font-normal text-muted-foreground">· {list.length}</span>
|
||||
</p>
|
||||
{!isNone && (
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
{t("domainResponsible")}: <b className="text-foreground">{resp ?? t("domainUnassigned")}</b>
|
||||
{fk && <span className="ml-1 text-[11px]">({fk})</span>}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-3 text-left">{t("code")}</th>
|
||||
<th className="p-3 text-left">{t("docTitle")}</th>
|
||||
<th className="p-3 text-left">{t("type")}</th>
|
||||
<th className="p-3 text-left">{t("status")}</th>
|
||||
{canWrite && <th className="p-3 text-left">{t("byDomain")}</th>}
|
||||
{canWrite && <th className="p-3 text-left">{t("actions")}</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((d) => (
|
||||
<tr key={d.id} className="border-b align-middle last:border-0">
|
||||
<td className="p-3 text-muted-foreground">{d.code}</td>
|
||||
<td className="p-3">
|
||||
<Link href={`/policies/${d.code}`} className="font-bold hover:underline">{d.title}</Link>
|
||||
</td>
|
||||
<td className="p-3"><Tag>{TYPE_LABEL[d.type]}</Tag></td>
|
||||
<td className="p-3"><Pill tone={STATUS_TONE[d.status]}>{STATUS_LABEL[d.status]}</Pill></td>
|
||||
{canWrite && (
|
||||
<td className="p-3">
|
||||
<form action={setPolicyDomain.bind(null, d.code)} className="flex items-center gap-1.5">
|
||||
<select name="domain" defaultValue={d.domain ?? ""} className="h-7 rounded-md border border-input bg-transparent px-1.5 text-[11.5px]">
|
||||
<option value="">— {t("domainSet")} —</option>
|
||||
{DOMAIN_ORDER.map((dom) => (
|
||||
<option key={dom} value={dom}>{DOMAIN_LABELS[dom]}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="outline" size="sm" className="h-7 px-2 text-[11px]">OK</Button>
|
||||
</form>
|
||||
</td>
|
||||
)}
|
||||
{canWrite && (
|
||||
<td className="p-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{EDITABLE_TYPES.has(d.type) && (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies/${d.code}/edit`} />}>{t("edit")}</Button>
|
||||
)}
|
||||
{d.status === "ENTWURF" && (
|
||||
approverUsers.length === 0 ? (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/policies/${d.code}/edit`} />}>{t("submitReview")}</Button>
|
||||
) : (
|
||||
<form action={submitForApproval.bind(null, d.code)} className="flex items-center gap-1.5">
|
||||
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
|
||||
<select name="approverId" required defaultValue="" className="h-7 rounded-md border border-input bg-transparent px-1.5 text-[11.5px]">
|
||||
<option value="" disabled>— Freigeber —</option>
|
||||
{approverUsers.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<Button type="submit" size="sm" className="h-7 px-2 text-[11px]"><Send className="size-3.5" /> {t("submitReview")}</Button>
|
||||
</form>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { Bold, Italic, Heading, List, Table, Link2, Plus } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Experten-Editor (Client): voller Markdown-/Vorlagen-Editor mit Formatierungs-
|
||||
* leiste und Einfüge-Hilfen für Variablen, Dokument-Links und Control-Verweise.
|
||||
* Speichert über die übergebene Server-Action; fehlende {{VARIABLEN}} werden
|
||||
* beim Speichern serverseitig automatisch angelegt.
|
||||
*/
|
||||
export function PolicyExpertEditor({
|
||||
formId,
|
||||
saveAction,
|
||||
initialMarkdown,
|
||||
variables,
|
||||
docs,
|
||||
}: {
|
||||
formId: string;
|
||||
saveAction: (formData: FormData) => void | Promise<void>;
|
||||
initialMarkdown: string;
|
||||
variables: { key: string; title: string }[];
|
||||
docs: { code: string; title: string }[];
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
const [value, setValue] = useState(initialMarkdown);
|
||||
const [newVar, setNewVar] = useState("");
|
||||
|
||||
function apply(fn: (sel: string, before: string) => { text: string; caret?: number }) {
|
||||
const ta = ref.current;
|
||||
if (!ta) return;
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const sel = value.slice(start, end);
|
||||
const before = value.slice(0, start);
|
||||
const { text } = fn(sel, before);
|
||||
const next = before + text + value.slice(end);
|
||||
setValue(next);
|
||||
requestAnimationFrame(() => {
|
||||
ta.focus();
|
||||
const pos = start + text.length;
|
||||
ta.setSelectionRange(pos, pos);
|
||||
});
|
||||
}
|
||||
|
||||
const wrap = (b: string, a: string, placeholder: string) =>
|
||||
apply((sel) => ({ text: `${b}${sel || placeholder}${a}` }));
|
||||
const insert = (t: string) => apply(() => ({ text: t }));
|
||||
|
||||
const insertVar = (key: string) => key && insert(`{{${key}}}`);
|
||||
const addNewVar = () => {
|
||||
const k = newVar.trim().toUpperCase().replace(/[^A-Z0-9_]/g, "_");
|
||||
if (k) {
|
||||
insert(`{{${k}}}`);
|
||||
setNewVar("");
|
||||
}
|
||||
};
|
||||
|
||||
const btn = "inline-flex h-8 items-center gap-1.5 rounded-md border border-input bg-transparent px-2.5 text-[12px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground";
|
||||
const sel = "h-8 rounded-md border border-input bg-transparent px-2 text-[12px] text-muted-foreground";
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Werkzeugleiste */}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<button type="button" className={btn} onClick={() => wrap("**", "**", "fett")} title="Fett"><Bold className="size-3.5" /></button>
|
||||
<button type="button" className={btn} onClick={() => wrap("_", "_", "kursiv")} title="Kursiv"><Italic className="size-3.5" /></button>
|
||||
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n## ${sel || "Überschrift"}\n` }))} title="Überschrift"><Heading className="size-3.5" /></button>
|
||||
<button type="button" className={btn} onClick={() => apply((sel) => ({ text: `\n- ${sel || "Punkt"}\n` }))} title="Liste"><List className="size-3.5" /></button>
|
||||
<button type="button" className={btn} onClick={() => insert("\n| Spalte A | Spalte B |\n|----------|----------|\n| Wert | Wert |\n")} title="Tabelle"><Table className="size-3.5" /></button>
|
||||
|
||||
<span className="mx-1 h-5 w-px bg-border" />
|
||||
|
||||
{/* Variablen einfügen */}
|
||||
<select className={sel} defaultValue="" onChange={(e) => { insertVar(e.target.value); e.currentTarget.value = ""; }} title="Variable einfügen">
|
||||
<option value="" disabled>Variable einfügen…</option>
|
||||
{variables.map((v) => (
|
||||
<option key={v.key} value={v.key}>{v.key} — {v.title}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Dokument-Link / Control-Referenz einfügen */}
|
||||
<select className={sel} defaultValue="" onChange={(e) => { if (e.target.value) insert(`{{LINK:${e.target.value}}}`); e.currentTarget.value = ""; }} title="Verweis einfügen">
|
||||
<option value="" disabled>Verweis einfügen…</option>
|
||||
<optgroup label="Dokumente & Register">
|
||||
{docs.map((d) => (
|
||||
<option key={d.code} value={d.code}>{d.code} — {d.title}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
<optgroup label="Zentrale Verweise">
|
||||
<option value="NACHWEISREGISTER">Nachweisregister</option>
|
||||
<option value="ISA_MAPPING">ISA-Mapping-Matrix</option>
|
||||
<option value="BASELINE">Technische Baseline</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<input
|
||||
value={newVar}
|
||||
onChange={(e) => setNewVar(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addNewVar(); } }}
|
||||
placeholder="Neue Variable"
|
||||
className="h-8 w-28 rounded-md border border-input bg-transparent px-2 text-[12px]"
|
||||
/>
|
||||
<button type="button" className={btn} onClick={addNewVar} title="Neue Variable einfügen"><Plus className="size-3.5" /></button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="mb-2 flex items-center gap-1.5 text-[11.5px] text-muted-foreground">
|
||||
<Link2 className="size-3.5" /> Deep-Link-Syntax: <code className="rounded bg-[var(--elevated)] px-1">{"{{LINK:R08#4.1.2}}"}</code> verweist auf ein Control. Neue Variablen werden beim Speichern angelegt.
|
||||
</p>
|
||||
|
||||
<form id={formId} action={saveAction}>
|
||||
<textarea
|
||||
ref={ref}
|
||||
name="rawMarkdown"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
rows={32}
|
||||
className="w-full rounded-xl border border-input bg-transparent p-3 font-mono text-[12px] leading-relaxed outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { PolicyDocument, PolicyVariable } from "@prisma/client";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { applyProtection, buildContext, renderPolicyHtml, splitPolicyDoc } from "@/lib/policy-render";
|
||||
|
||||
export const POLICY_TYPE_LABEL: Record<string, string> = {
|
||||
LEITLINIE: "Leitlinie",
|
||||
RICHTLINIE: "Richtlinie",
|
||||
VERFAHREN: "Verfahren",
|
||||
REGISTER: "Register",
|
||||
HANDBUCH: "Handbuch",
|
||||
EIGENES: "Eigenes",
|
||||
};
|
||||
export const POLICY_STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
||||
FREIGEGEBEN: "ok",
|
||||
IN_FREIGABE: "info",
|
||||
ENTWURF: "warn",
|
||||
ARCHIVIERT: "mut",
|
||||
};
|
||||
export const POLICY_STATUS_LABEL: Record<string, string> = {
|
||||
FREIGEGEBEN: "Freigegeben",
|
||||
IN_FREIGABE: "In Freigabe",
|
||||
ENTWURF: "Entwurf",
|
||||
ARCHIVIERT: "Archiviert",
|
||||
};
|
||||
|
||||
// Dokumente, die eine verwaltete Register-Tabelle einbetten (§7b) → „Vollständige Tabelle öffnen"
|
||||
const EMBEDDED_REGISTER: Record<string, { code: string; label: string }> = {
|
||||
R09: { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
|
||||
"VA-07": { code: "CRYPTO", label: "Verschlüsselungsmechanismen-Register" },
|
||||
R03: { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
|
||||
"VA-09": { code: "RISKMATRIX", label: "Risiko-Bewertungsmatrix" },
|
||||
R02: { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
|
||||
"VA-08": { code: "CLASSIFICATION", label: "Klassifizierung & Handhabungsmatrix" },
|
||||
};
|
||||
|
||||
export interface PolicyReadData {
|
||||
doc: PolicyDocument;
|
||||
variables: PolicyVariable[];
|
||||
/** ISA-Controls dieser Richtlinie (für Chips). */
|
||||
controls: string[];
|
||||
/** Operationalisierende Verfahren (Codes) — aus den Anforderungen der Richtlinie. */
|
||||
relatedVas: string[];
|
||||
}
|
||||
|
||||
/** Lesemodus-Inhalt: gerendertes Dokument aus der echten Markdown-Vorlage (auf eigener Seite). */
|
||||
export async function PolicyReadView({ data }: { data: PolicyReadData }) {
|
||||
const t = await getTranslations("policies");
|
||||
const { doc, variables, controls, relatedVas } = data;
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const { infoMd, bodyMd } = splitPolicyDoc(doc.rawMarkdown);
|
||||
|
||||
const stripBaseline = doc.code !== "BASELINE";
|
||||
const infoHtml = renderPolicyHtml(infoMd, ctx, { readMode: true, stripBaseline: false });
|
||||
const bodyHtml = renderPolicyHtml(bodyMd, ctx, { readMode: true, stripBaseline });
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Kontext: Controls / operationalisiert-durch */}
|
||||
{(controls.length > 0 || doc.policyCode || relatedVas.length > 0) && (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-[12px]">
|
||||
{doc.type === "VERFAHREN" && doc.policyCode && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("operationalizes")}{" "}
|
||||
<Link href={`/policies/${doc.policyCode}`} className="font-semibold text-[var(--info)] hover:underline">
|
||||
{doc.policyCode}
|
||||
</Link>
|
||||
</span>
|
||||
)}
|
||||
{controls.map((c) => (
|
||||
<Pill key={c} tone="violet">
|
||||
ISA {c}
|
||||
</Pill>
|
||||
))}
|
||||
{doc.type !== "VERFAHREN" && relatedVas.length > 0 && (
|
||||
<span className="ml-1 flex flex-wrap items-center gap-1.5 text-muted-foreground">
|
||||
{t("procedures")}:
|
||||
{relatedVas.map((va) => (
|
||||
<Link key={va} href={`/policies/${va}`} className="font-semibold text-[var(--info)] hover:underline">
|
||||
{va}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Einklappbare Dokumenten-Info (§7a.5, Standard: eingeklappt) */}
|
||||
{infoMd && (
|
||||
<details className="mb-4 rounded-xl border bg-[var(--surface-soft)]">
|
||||
<summary className="cursor-pointer list-none px-4 py-2.5 text-[12.5px] font-semibold text-muted-foreground select-none hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
{t("docInfo")}
|
||||
</summary>
|
||||
<div className="policy-prose border-t px-4 py-2" dangerouslySetInnerHTML={{ __html: infoHtml }} />
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* Eingebettete verwaltete Tabelle (§7b) */}
|
||||
{EMBEDDED_REGISTER[doc.code] && (
|
||||
<Link
|
||||
href={`/policies/${EMBEDDED_REGISTER[doc.code].code}`}
|
||||
className="mb-4 flex items-center justify-between gap-2 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] px-4 py-2.5 text-[12.5px] text-[var(--band-text)] hover:opacity-90"
|
||||
>
|
||||
<span>
|
||||
<b>Verwaltete Tabelle:</b> {EMBEDDED_REGISTER[doc.code].label}
|
||||
</span>
|
||||
<span className="font-semibold">{t("openFullTable")} →</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Gerendertes Dokument */}
|
||||
<article className="policy-prose" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter } from "next-intl/server";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ClassificationClass,
|
||||
CryptoEntry,
|
||||
HandbookTopic,
|
||||
HandlingAspect,
|
||||
HandlingRule,
|
||||
PolicyDocument,
|
||||
PolicyVariable,
|
||||
RiskDamageDimension,
|
||||
RiskEwLevel,
|
||||
RiskMatrixClass,
|
||||
} from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { isExpired, isExpiring } from "@/lib/supplier";
|
||||
import { applyProtection, buildContext, renderPolicyHtml, resolveLink } from "@/lib/policy-render";
|
||||
import {
|
||||
addClassificationClass,
|
||||
addCryptoEntry,
|
||||
addHandlingAspect,
|
||||
deleteCryptoEntry,
|
||||
updateCryptoEntry,
|
||||
updateDamageDimension,
|
||||
updateEwLevel,
|
||||
updateHandlingRule,
|
||||
updateRiskClass,
|
||||
} from "@/server/actions/policies";
|
||||
|
||||
const TONE_HEX: Record<string, string> = { ok: "#2ea86b", warn: "#e3b427", orange: "#e2802e", risk: "#d63c5e" };
|
||||
|
||||
export interface RegisterData {
|
||||
crypto?: CryptoEntry[];
|
||||
classes?: ClassificationClass[];
|
||||
aspects?: HandlingAspect[];
|
||||
rules?: HandlingRule[];
|
||||
riskClasses?: RiskMatrixClass[];
|
||||
ewLevels?: RiskEwLevel[];
|
||||
damage?: RiskDamageDimension[];
|
||||
handbook?: HandbookTopic[];
|
||||
variables?: PolicyVariable[];
|
||||
}
|
||||
|
||||
/** Inhalt der verwalteten Register (§7b) und des Anwender-Handbuchs (§9.7) — auf eigener Seite. */
|
||||
export async function PolicyRegisterView({
|
||||
doc,
|
||||
data,
|
||||
canWrite,
|
||||
}: {
|
||||
doc: PolicyDocument;
|
||||
data: RegisterData;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{doc.code === "CRYPTO" && <CryptoRegister entries={data.crypto ?? []} canWrite={canWrite} />}
|
||||
{doc.code === "CLASSIFICATION" && (
|
||||
<ClassificationMatrix classes={data.classes ?? []} aspects={data.aspects ?? []} rules={data.rules ?? []} canWrite={canWrite} />
|
||||
)}
|
||||
{doc.code === "RISKMATRIX" && (
|
||||
<RiskMatrix classes={data.riskClasses ?? []} ew={data.ewLevels ?? []} damage={data.damage ?? []} canWrite={canWrite} />
|
||||
)}
|
||||
{doc.code === "HANDBUCH" && <Handbook topics={data.handbook ?? []} variables={data.variables ?? []} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Krypto-Register ─────────────── */
|
||||
|
||||
async function CryptoRegister({ entries, canWrite }: { entries: CryptoEntry[]; canWrite: boolean }) {
|
||||
const fmt = await getFormatter();
|
||||
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : "—");
|
||||
const expiring = entries.filter((e) => isExpiring(e.ablaufdatum) || isExpired(e.ablaufdatum)).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between gap-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Eingesetzte Verschlüsselung mit Ablaufüberwachung.{" "}
|
||||
{expiring > 0 && <span className="font-semibold text-[var(--warn)]">{expiring} laufen bald ab / abgelaufen.</span>}
|
||||
</p>
|
||||
{canWrite && (
|
||||
<details className="relative">
|
||||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||||
<Plus className="size-4" /> Eintrag
|
||||
</summary>
|
||||
<form action={addCryptoEntry} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="dienst" required placeholder="Dienst" />
|
||||
<Input name="schluessel" required placeholder="Schlüssel" />
|
||||
<Input name="algorithmus" placeholder="Algorithmus / Länge" />
|
||||
<Input name="ablaufdatum" type="date" />
|
||||
<Input name="verantwortlich" placeholder="Verantwortlich" />
|
||||
<Input name="speicherort" placeholder="Speicherort" />
|
||||
<Input name="baselineRef" placeholder="Baseline (BL-CRY-…)" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)] text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<th className="p-2.5 text-left">Dienst</th>
|
||||
<th className="p-2.5 text-left">Schlüssel</th>
|
||||
<th className="p-2.5 text-left">Algorithmus</th>
|
||||
<th className="p-2.5 text-left">Ablauf</th>
|
||||
<th className="p-2.5 text-left">Verantwortlich</th>
|
||||
<th className="p-2.5 text-left">Baseline</th>
|
||||
{canWrite && <th className="p-2.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && (
|
||||
<tr><td colSpan={7} className="p-6 text-center text-muted-foreground">Noch keine Einträge.</td></tr>
|
||||
)}
|
||||
{entries.map((e) => {
|
||||
const exp = isExpired(e.ablaufdatum);
|
||||
const soon = !exp && isExpiring(e.ablaufdatum);
|
||||
return (
|
||||
<tr key={e.id} className="border-b last:border-0">
|
||||
<td className="p-2.5 font-medium">{e.dienst}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.schluessel}</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.algorithmus ?? "—"}</td>
|
||||
<td className="p-2.5">
|
||||
<span className={exp ? "font-semibold text-[var(--risk)]" : soon ? "font-semibold text-[var(--warn)]" : "text-muted-foreground"}>
|
||||
{date(e.ablaufdatum)}
|
||||
</span>
|
||||
{exp && <Pill tone="risk">abgelaufen</Pill>}
|
||||
{soon && <Pill tone="warn">läuft ab</Pill>}
|
||||
</td>
|
||||
<td className="p-2.5 text-muted-foreground">{e.verantwortlich}</td>
|
||||
<td className="p-2.5">{e.baselineRef ? <Pill tone="violet">{e.baselineRef}</Pill> : <span className="text-muted-foreground">—</span>}</td>
|
||||
{canWrite && (
|
||||
<td className="p-2.5">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<details className="relative">
|
||||
<summary className="grid size-7 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Pencil className="size-3.5" /></summary>
|
||||
<form action={updateCryptoEntry.bind(null, e.id)} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="dienst" required defaultValue={e.dienst} />
|
||||
<Input name="schluessel" required defaultValue={e.schluessel} />
|
||||
<Input name="algorithmus" defaultValue={e.algorithmus ?? ""} placeholder="Algorithmus" />
|
||||
<Input name="ablaufdatum" type="date" defaultValue={e.ablaufdatum ? e.ablaufdatum.toISOString().slice(0, 10) : ""} />
|
||||
<Input name="verantwortlich" defaultValue={e.verantwortlich} />
|
||||
<Input name="speicherort" defaultValue={e.speicherort ?? ""} placeholder="Speicherort" />
|
||||
<Input name="baselineRef" defaultValue={e.baselineRef ?? ""} placeholder="BL-CRY-…" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
<form action={deleteCryptoEntry.bind(null, e.id)}>
|
||||
<button type="submit" title="Löschen" className="grid size-7 place-items-center rounded-md text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Klassifizierungs-Handhabungsmatrix ─────────────── */
|
||||
|
||||
function ClassificationMatrix({
|
||||
classes, aspects, rules, canWrite,
|
||||
}: { classes: ClassificationClass[]; aspects: HandlingAspect[]; rules: HandlingRule[]; canWrite: boolean }) {
|
||||
const ruleOf = (classId: string, aspectId: string) => rules.find((r) => r.classId === classId && r.aspectId === aspectId)?.text ?? "";
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">Handhabungsregeln je Schutzklasse. Assets erben die Regeln aus ihrer Klasse.</p>
|
||||
{canWrite && (
|
||||
<div className="flex gap-2">
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Aspekt</summary>
|
||||
<form action={addHandlingAspect} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required placeholder="Aspekt (Zeile)" />
|
||||
<Input name="category" placeholder="Kategorie" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border px-2.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden"><Plus className="size-3.5" /> Schutzklasse</summary>
|
||||
<form action={addClassificationClass} className="shadow-card absolute right-0 z-10 mt-2 w-72 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="name" required placeholder="Schutzklasse (Spalte)" />
|
||||
<Input name="description" placeholder="Beschreibung" />
|
||||
<Button type="submit" variant="secondary" size="sm">Hinzufügen</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="overflow-x-auto rounded-xl border">
|
||||
<table className="w-full text-[12.5px]">
|
||||
<thead>
|
||||
<tr className="border-b bg-[var(--elevated)]">
|
||||
<th className="p-2.5 text-left text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">Aspekt</th>
|
||||
{classes.map((c) => (
|
||||
<th key={c.id} className="min-w-40 p-2.5 text-left font-heading font-bold" title={c.description ?? ""}>{c.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{aspects.map((a) => (
|
||||
<tr key={a.id} className="border-b last:border-0 align-top">
|
||||
<td className="p-2.5 font-medium">{a.name}</td>
|
||||
{classes.map((c) => (
|
||||
<td key={c.id} className="p-1.5">
|
||||
{canWrite ? (
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer list-none rounded-md px-2 py-1.5 hover:bg-muted/50 [&::-webkit-details-marker]:hidden">{ruleOf(c.id, a.id) || <span className="text-muted-foreground">—</span>}</summary>
|
||||
<form action={updateHandlingRule.bind(null, c.id, a.id)} className="mt-1 flex gap-1">
|
||||
<Input name="text" defaultValue={ruleOf(c.id, a.id)} className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">OK</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<span className="px-2">{ruleOf(c.id, a.id) || "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Risiko-Bewertungsmatrix ─────────────── */
|
||||
|
||||
function RiskMatrix({
|
||||
classes, ew, damage, canWrite,
|
||||
}: { classes: RiskMatrixClass[]; ew: RiskEwLevel[]; damage: RiskDamageDimension[]; canWrite: boolean }) {
|
||||
const sorted = [...classes].sort((a, b) => a.maxScore - b.maxScore);
|
||||
const classFor = (score: number) => sorted.find((c) => score <= c.maxScore) ?? sorted[sorted.length - 1];
|
||||
const rows = [4, 3, 2, 1]; // Schadensausmaß (oben = 4)
|
||||
const cols = [1, 2, 3, 4]; // Eintrittswahrscheinlichkeit
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* 4×4 Heatmap */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Bewertungsmatrix (Schadensausmaß × Eintrittswahrscheinlichkeit)</p>
|
||||
<div className="inline-block overflow-hidden rounded-xl border">
|
||||
<table className="text-center text-[12.5px]">
|
||||
<tbody>
|
||||
{rows.map((s) => (
|
||||
<tr key={s}>
|
||||
<th className="bg-[var(--elevated)] px-3 py-2 text-muted-foreground">S {s}</th>
|
||||
{cols.map((e) => {
|
||||
const score = s * e;
|
||||
const cls = classFor(score);
|
||||
return (
|
||||
<td key={e} className="px-4 py-2 font-bold text-white" style={{ background: TONE_HEX[cls?.tone] ?? "#555" }} title={`${cls?.name} (${score})`}>
|
||||
{score}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<th className="bg-[var(--elevated)] px-3 py-2" />
|
||||
{cols.map((e) => (
|
||||
<th key={e} className="bg-[var(--elevated)] px-4 py-2 text-muted-foreground">EW {e}</th>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Risikoklassen + Akzeptanzinstanz — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Risikoklassen & Akzeptanz {canWrite && <span className="text-[11px] font-normal text-muted-foreground">· zum Bearbeiten auf eine Zeile klicken</span>}</p>
|
||||
<div className="rounded-xl border">
|
||||
<div className="grid grid-cols-[8rem_7rem_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase">
|
||||
<span>Klasse</span><span>Bis Risikowert</span><span>Akzeptanzinstanz</span>
|
||||
</div>
|
||||
{sorted.map((c) => (
|
||||
canWrite ? (
|
||||
<details key={c.id} className="border-b last:border-0">
|
||||
<summary className="grid cursor-pointer grid-cols-[8rem_7rem_1fr] items-center gap-x-3 px-3 py-2.5 text-sm select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
|
||||
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
|
||||
<span className="font-medium">≤ {c.maxScore}</span>
|
||||
<span className="flex items-center justify-between gap-2 text-muted-foreground">{c.acceptance}<Pencil className="size-3.5 shrink-0 opacity-60" /></span>
|
||||
</summary>
|
||||
<form action={updateRiskClass.bind(null, c.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[8rem_7rem_1fr_auto]">
|
||||
<Input name="name" required defaultValue={c.name} placeholder="Klasse" className="h-8" />
|
||||
<Input name="maxScore" type="number" required defaultValue={c.maxScore} placeholder="≤ Wert" className="h-8" />
|
||||
<Input name="acceptance" defaultValue={c.acceptance} placeholder="Akzeptanzinstanz" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={c.id} className="grid grid-cols-[8rem_7rem_1fr] items-center gap-x-3 border-b px-3 py-2.5 text-sm last:border-0">
|
||||
<span><Pill tone={c.tone as "ok" | "warn" | "orange" | "risk"}>{c.name}</Pill></span>
|
||||
<span className="font-medium">≤ {c.maxScore}</span>
|
||||
<span className="text-muted-foreground">{c.acceptance}</span>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Eintrittswahrscheinlichkeit — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Eintrittswahrscheinlichkeit</p>
|
||||
<div className="rounded-xl border">
|
||||
{[...ew].sort((a, b) => a.level - b.level).map((l) => (
|
||||
canWrite ? (
|
||||
<details key={l.id} className="border-b last:border-0">
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-2 px-3 py-2 text-[12.5px] select-none hover:bg-muted/40 [&::-webkit-details-marker]:hidden">
|
||||
<span><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></span>
|
||||
<Pencil className="size-3.5 shrink-0 opacity-60" />
|
||||
</summary>
|
||||
<form action={updateEwLevel.bind(null, l.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-[10rem_1fr_auto]">
|
||||
<Input name="label" required defaultValue={l.label} placeholder="Bezeichnung" className="h-8" />
|
||||
<Input name="definition" defaultValue={l.definition} placeholder="Definition" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={l.id} className="border-b px-3 py-2 text-[12.5px] last:border-0"><b>{l.level} · {l.label}</b> <span className="text-muted-foreground">— {l.definition}</span></div>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Schadenskategorien — inline editierbar */}
|
||||
<section>
|
||||
<p className="mb-2 font-heading text-sm font-semibold">Schadenskategorien</p>
|
||||
<div className="rounded-xl border">
|
||||
<div className="hidden grid-cols-[12rem_1fr_1fr_1fr_1fr] gap-x-3 border-b bg-[var(--elevated)] px-3 py-2 text-[10.5px] tracking-[.04em] text-muted-foreground uppercase sm:grid">
|
||||
<span>Dimension</span><span>1 Niedrig</span><span>2 Normal</span><span>3 Hoch</span><span>4 Sehr hoch</span>
|
||||
</div>
|
||||
{damage.map((d) => {
|
||||
const lv = d.levels as Record<string, string>;
|
||||
return canWrite ? (
|
||||
<details key={d.id} className="border-b last:border-0">
|
||||
<summary className="grid cursor-pointer grid-cols-1 items-center gap-x-3 px-3 py-2.5 text-[12px] select-none hover:bg-muted/40 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr] [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center justify-between gap-2 font-medium">{d.name}<Pencil className="size-3.5 shrink-0 opacity-60 sm:hidden" /></span>
|
||||
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
|
||||
</summary>
|
||||
<form action={updateDamageDimension.bind(null, d.id)} className="grid gap-2 border-t bg-[var(--surface-soft)] p-3 sm:grid-cols-2">
|
||||
<Input name="name" required defaultValue={d.name} placeholder="Dimension" className="h-8 sm:col-span-2" />
|
||||
<Input name="l1" defaultValue={lv["1"]} placeholder="1 Niedrig" className="h-8" />
|
||||
<Input name="l2" defaultValue={lv["2"]} placeholder="2 Normal" className="h-8" />
|
||||
<Input name="l3" defaultValue={lv["3"]} placeholder="3 Hoch" className="h-8" />
|
||||
<Input name="l4" defaultValue={lv["4"]} placeholder="4 Sehr hoch" className="h-8" />
|
||||
<Button type="submit" variant="secondary" size="sm" className="sm:col-span-2">Speichern</Button>
|
||||
</form>
|
||||
</details>
|
||||
) : (
|
||||
<div key={d.id} className="grid grid-cols-1 gap-x-3 border-b px-3 py-2.5 text-[12px] last:border-0 sm:grid-cols-[12rem_1fr_1fr_1fr_1fr]">
|
||||
<span className="font-medium">{d.name}</span>
|
||||
<span className="text-muted-foreground">{lv["1"]}</span><span className="text-muted-foreground">{lv["2"]}</span><span className="text-muted-foreground">{lv["3"]}</span><span className="text-muted-foreground">{lv["4"]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────── Anwender-Handbuch ─────────────── */
|
||||
|
||||
function Handbook({ topics, variables }: { topics: HandbookTopic[]; variables: PolicyVariable[] }) {
|
||||
const ctx = applyProtection(buildContext(variables));
|
||||
const cats = [...new Set(topics.map((t) => t.category))];
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Kompakte Regeln für Mitarbeitende. Werte (z. B. Passwortlänge) stammen aus der zentralen Baseline und bleiben automatisch synchron.
|
||||
</p>
|
||||
{cats.map((cat) => (
|
||||
<section key={cat}>
|
||||
<p className="mb-2 font-heading text-sm font-semibold text-[var(--band-text)]">{cat}</p>
|
||||
<div className="space-y-3">
|
||||
{topics.filter((t) => t.category === cat).map((topic) => (
|
||||
<div key={topic.id} className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<p className="font-heading text-[14px] font-bold">{topic.title}</p>
|
||||
<div className="policy-prose mt-1" dangerouslySetInnerHTML={{ __html: renderPolicyHtml(topic.bodyMd, ctx, { readMode: false }) }} />
|
||||
{topic.sourceRefs.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[12px] text-muted-foreground">
|
||||
Details:
|
||||
{topic.sourceRefs.map((ref) => {
|
||||
const { href, label } = resolveLink(ref);
|
||||
return (
|
||||
<Link key={ref} href={href} className="font-semibold text-[var(--info)] hover:underline">{label} →</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { Process } from "@prisma/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
/** Formular für Anlegen/Bearbeiten eines Prozesses (Server-Action wird übergeben). */
|
||||
export async function ProcessForm({
|
||||
action,
|
||||
process,
|
||||
users,
|
||||
processes = [],
|
||||
cancelHref,
|
||||
returnTo,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
process?: Process;
|
||||
users: { id: string; name: string }[];
|
||||
/** Auswahl möglicher Hauptprozesse (Haupt-/Teilprozess-Hierarchie). */
|
||||
processes?: { id: string; name: string }[];
|
||||
cancelHref: string;
|
||||
/** Weiterleitung nach dem Anlegen ("house" → direkt ins BIA-Popup). */
|
||||
returnTo?: string;
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
const selectClass = "mt-1 h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<form action={action} className="max-w-2xl space-y-4">
|
||||
{returnTo && <input type="hidden" name="returnTo" value={returnTo} />}
|
||||
<div>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={process?.name} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="purpose">{t("purpose")}</Label>
|
||||
<Textarea
|
||||
id="purpose"
|
||||
name="purpose"
|
||||
rows={2}
|
||||
defaultValue={process?.purpose ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={process?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<select id="category" name="category" defaultValue={process?.category ?? "CORE"} className={selectClass}>
|
||||
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tCat(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="parentId">{t("parent")}</Label>
|
||||
<select id="parentId" name="parentId" defaultValue={process?.parentId ?? ""} className={selectClass}>
|
||||
<option value="">{t("noParent")}</option>
|
||||
{processes
|
||||
.filter((p) => p.id !== process?.id)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select id="ownerId" name="ownerId" defaultValue={process?.ownerId ?? ""} className={selectClass}>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="deputyOwnerId">{t("deputyOwner")}</Label>
|
||||
<select id="deputyOwnerId" name="deputyOwnerId" defaultValue={process?.deputyOwnerId ?? ""} className={selectClass}>
|
||||
<option value="">{t("noDeputy")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="legalBasis">{t("legalBasis")}</Label>
|
||||
<Input id="legalBasis" name="legalBasis" defaultValue={process?.legalBasis ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="interfaces">{t("interfaces")}</Label>
|
||||
<Input id="interfaces" name="interfaces" defaultValue={process?.interfaces ?? ""} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-5">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="dataProtectionRelevant"
|
||||
defaultChecked={process?.dataProtectionRelevant ?? false}
|
||||
className="size-4 rounded border-input"
|
||||
/>
|
||||
{t("dataProtectionRelevant")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="prototypeRelevant"
|
||||
defaultChecked={process?.prototypeRelevant ?? false}
|
||||
className="size-4 rounded border-input"
|
||||
/>
|
||||
{t("prototypeRelevant")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={cancelHref} />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,927 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { MoreHorizontal, Network, Pencil, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addProcessDependency,
|
||||
assignAsset,
|
||||
createProcess,
|
||||
deleteProcess,
|
||||
removeProcessDependency,
|
||||
saveProcessAll,
|
||||
unassignAsset,
|
||||
} from "@/server/actions/processes";
|
||||
import { ProcessForm } from "@/components/process-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
|
||||
export type LinkedRisk = {
|
||||
id: string;
|
||||
refNo: number;
|
||||
title: string;
|
||||
score: number;
|
||||
};
|
||||
|
||||
export type ProcessWithDetail = Prisma.ProcessGetPayload<{
|
||||
include: {
|
||||
owner: { select: { name: true } };
|
||||
parent: { select: { name: true } };
|
||||
bia: true;
|
||||
processAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
type: true;
|
||||
confidentiality: true;
|
||||
integrity: true;
|
||||
availability: true;
|
||||
owner: { select: { name: true } };
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
dependsOn: { select: { id: true; note: true; target: { select: { id: true; name: true } } } };
|
||||
requiredBy: { select: { id: true; note: true; source: { select: { id: true; name: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
|
||||
/** Anlegen als Popup (Stammdaten; Assets/BIA folgen im Bearbeiten-Popup bzw. BIA-Popup). */
|
||||
export async function ProcessCreateModal({
|
||||
users,
|
||||
processes = [],
|
||||
closeHref = "/processes",
|
||||
returnTo,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
processes?: { id: string; name: string }[];
|
||||
/** Ziel für Schließen/Abbrechen (Overlay). Prozesshaus: „/onboarding?step=processes". */
|
||||
closeHref?: string;
|
||||
/** Weiterleitung nach dem Anlegen ("house" → direkt ins BIA-Popup). */
|
||||
returnTo?: string;
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref={closeHref} closeLabel={t("close")}>
|
||||
<div className="p-5">
|
||||
<ProcessForm
|
||||
action={createProcess}
|
||||
users={users}
|
||||
processes={processes}
|
||||
cancelHref={closeHref}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Payload für das read-only Prozesshaus-Detail-Overlay (fachliche Infos + BIA-Kurz). */
|
||||
export type ProcessHouseDetail = Prisma.ProcessGetPayload<{
|
||||
include: {
|
||||
owner: { select: { name: true } };
|
||||
parent: { select: { name: true } };
|
||||
children: { select: { id: true; name: true } };
|
||||
bia: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* TISAX v4B — „Details" im Prozesshaus als read-only Overlay-Popup. Zeigt die
|
||||
* fachlichen Prozess-Informationen (Zweck, Owner/Stellvertreter, Hierarchie,
|
||||
* Rechtsgrundlage, Schnittstellen, DS-/Prototyp-Relevanz) plus eine BIA-Kurzübersicht
|
||||
* — OHNE die Prozesshaus-Hauptseite zu verändern (wird als Sibling über die Seite
|
||||
* gelegt, Zustand via `?detail=` der Onboarding-Seite). „Details" ≠ „BIA öffnen":
|
||||
* Letzteres bleibt der Bearbeitungs-Flow (optionaler Button `biaHref`).
|
||||
*/
|
||||
export async function ProcessHouseDetailModal({
|
||||
process,
|
||||
deputyName,
|
||||
closeHref,
|
||||
biaHref,
|
||||
}: {
|
||||
process: ProcessHouseDetail;
|
||||
/** Aufgelöster Name des Stellvertreters (deputyOwnerId hat keine Prisma-Relation). */
|
||||
deputyName?: string | null;
|
||||
/** Ziel für Schließen (zurück ins Prozesshaus, ohne die Hauptseite zu ändern). */
|
||||
closeHref: string;
|
||||
/** Optional: Link ins geführte BIA-Popup (Bearbeitungs-Flow). */
|
||||
biaHref?: string;
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
const tHouse = await getTranslations("processHouse");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("detailHeading", { name: process.name })}
|
||||
sub={tHouse("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tCat(process.category)}</Tag>
|
||||
{process.bia && (
|
||||
<CriticalityPill
|
||||
level={process.bia.criticality}
|
||||
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
closeHref={closeHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{biaHref && (
|
||||
<Button nativeButton={false} render={<Link href={biaHref} />}>
|
||||
{tHouse("openBia")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={closeHref} />}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Fachliche Prozess-Informationen (read-only) */}
|
||||
<div className="mx-5 mt-5 rounded-xl border bg-[var(--surface-soft)] p-4">
|
||||
<p className="mb-2 font-heading text-[13px] font-semibold">{t("businessInfo")}</p>
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-[12.5px] sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">{t("purpose")}</dt>
|
||||
<dd>{process.purpose || tc("none")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{process.owner?.name ?? tc("none")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("deputyOwner")}</dt>
|
||||
<dd>{deputyName ?? t("noDeputy")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("parent")}</dt>
|
||||
<dd>{process.parent?.name ?? t("noParent")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{tHouse("children")}</dt>
|
||||
<dd>
|
||||
{process.children.length === 0
|
||||
? tHouse("noChildren")
|
||||
: process.children.map((c) => c.name).join(", ")}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("legalBasis")}</dt>
|
||||
<dd>{process.legalBasis || tc("none")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("interfaces")}</dt>
|
||||
<dd>{process.interfaces || tc("none")}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
|
||||
{process.dataProtectionRelevant && <Pill tone="info">{t("dataProtectionRelevant")}</Pill>}
|
||||
{process.prototypeRelevant && <Pill tone="violet">{t("prototypeRelevant")}</Pill>}
|
||||
{process.catalogCode && <Tag>{t("catalogCode")}: {process.catalogCode}</Tag>}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* BIA-Kurzübersicht (read-only) */}
|
||||
<div className="mx-5 mt-4 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<b>RTO</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.rtoHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>RPO</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.rpoHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>MTD</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.mtdHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>{t("impactShort")}</b>
|
||||
<div className="mt-1">
|
||||
{process.bia ? (
|
||||
<CiaBadge c={process.bia.impactC} i={process.bia.impactI} a={process.bia.impactA} />
|
||||
) : (
|
||||
t("noBia")
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{process.bia?.notes && (
|
||||
<p className="mt-3 border-t border-[var(--band-brd)] pt-2.5">{process.bia.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only-Prozess-Detail als Popup (Mockup: "Prozess-Detail · …"). */
|
||||
export async function ProcessDetailModal({
|
||||
process,
|
||||
risks,
|
||||
canWrite,
|
||||
deputyName,
|
||||
}: {
|
||||
process: ProcessWithDetail;
|
||||
risks: LinkedRisk[];
|
||||
canWrite: boolean;
|
||||
/** Aufgelöster Name des Stellvertreters (deputyOwnerId hat keine Prisma-Relation). */
|
||||
deputyName?: string | null;
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const hours = (v: number | null | undefined) => (v != null ? `${v} h` : tc("none"));
|
||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("detailHeading", { name: process.name })}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tCat(process.category)}</Tag>
|
||||
{process.bia && (
|
||||
<CriticalityPill
|
||||
level={process.bia.criticality}
|
||||
label={t("critLabel", { label: tCrit(String(process.bia.criticality)) })}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
closeHref="/processes"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/processes?edit=${process.id}`} />}
|
||||
>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/processes" />}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="violet">{t("primaryPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("primaryNote")}</span>
|
||||
</div>
|
||||
{primary.length === 0 && <p className="text-sm text-muted-foreground">{t("noAssets")}</p>}
|
||||
{primary.map((pa) => (
|
||||
<div
|
||||
key={pa.id}
|
||||
className="mb-2 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">
|
||||
<b>{pa.asset.name}</b>
|
||||
<CiaBadge
|
||||
c={pa.asset.confidentiality}
|
||||
i={pa.asset.integrity}
|
||||
a={pa.asset.availability}
|
||||
labels
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{t("owner")}: {pa.asset.owner?.name ?? tc("none")} · {tAssets("type")}:{" "}
|
||||
{tType(pa.asset.type)} · {t("inherits")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{t("secondaryPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("secondaryNote")}</span>
|
||||
</div>
|
||||
{secondary.length === 0 && <p className="text-sm text-muted-foreground">{t("noAssets")}</p>}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="pb-1 text-right font-normal">
|
||||
<CiaLegend />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{secondary.map((pa) => (
|
||||
<tr key={pa.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">{pa.asset.name}</td>
|
||||
<td className="py-2.5 pr-2">
|
||||
<Tag>{tType(pa.asset.type)}</Tag>
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge
|
||||
c={pa.asset.confidentiality}
|
||||
i={pa.asset.integrity}
|
||||
a={pa.asset.availability}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<Link
|
||||
href="/dependencies"
|
||||
className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline"
|
||||
>
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* v3A: fachliche Prozess-Informationen */}
|
||||
<div className="mx-5 mb-5 rounded-xl border bg-[var(--surface-soft)] p-4">
|
||||
<p className="mb-2 font-heading text-[13px] font-semibold">{t("businessInfo")}</p>
|
||||
<dl className="grid gap-x-6 gap-y-1.5 text-[12.5px] sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-muted-foreground">{t("purpose")}</dt>
|
||||
<dd>{process.purpose || tc("none")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("parent")}</dt>
|
||||
<dd>{process.parent?.name ?? t("noParent")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("deputyOwner")}</dt>
|
||||
<dd>{deputyName ?? t("noDeputy")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("legalBasis")}</dt>
|
||||
<dd>{process.legalBasis || tc("none")}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">{t("interfaces")}</dt>
|
||||
<dd>{process.interfaces || tc("none")}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex flex-wrap gap-2 pt-1">
|
||||
{process.dataProtectionRelevant && <Pill tone="info">{t("dataProtectionRelevant")}</Pill>}
|
||||
{process.prototypeRelevant && <Pill tone="violet">{t("prototypeRelevant")}</Pill>}
|
||||
{process.catalogCode && <Tag>{t("catalogCode")}: {process.catalogCode}</Tag>}
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Prozess-Abhängigkeiten (benötigt / wird benötigt von) */}
|
||||
<div className="mx-5 mb-5 rounded-xl border bg-[var(--surface-soft)] p-4">
|
||||
<p className="mb-2 font-heading text-[13px] font-semibold">{t("depsSection")}</p>
|
||||
<dl className="grid gap-x-6 gap-y-2 text-[12.5px] sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-muted-foreground">↳ {t("depsRequires")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap gap-1.5">
|
||||
{process.dependsOn.length === 0
|
||||
? tc("none")
|
||||
: process.dependsOn.map((d) => (
|
||||
<Pill key={d.id} tone="violet">
|
||||
{d.target.name}
|
||||
{d.note ? ` · ${d.note}` : ""}
|
||||
</Pill>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground">▲ {t("depsRequiredBy")}</dt>
|
||||
<dd className="mt-1 flex flex-wrap gap-1.5">
|
||||
{process.requiredBy.length === 0
|
||||
? tc("none")
|
||||
: process.requiredBy.map((d) => (
|
||||
<Pill key={d.id} tone="info">
|
||||
{d.source.name}
|
||||
</Pill>
|
||||
))}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="mx-5 mb-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4 text-[12.5px] text-[var(--band-text)]">
|
||||
<div className="grid gap-2 sm:grid-cols-4">
|
||||
<div>
|
||||
<b>RTO</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.rtoHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>RPO</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.rpoHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>MTD</b>
|
||||
<div className="mt-0.5">{hours(process.bia?.mtdHours)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<b>{t("impactShort")}</b>
|
||||
<div className="mt-1">
|
||||
{process.bia ? (
|
||||
<CiaBadge c={process.bia.impactC} i={process.bia.impactI} a={process.bia.impactA} />
|
||||
) : (
|
||||
t("noBia")
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{process.bia?.notes && (
|
||||
<p className="mt-3 border-t border-[var(--band-brd)] pt-2.5">{process.bia.notes}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mx-5 mb-5">
|
||||
<p className="text-sm font-semibold">{t("linkedRisks")}</p>
|
||||
{risks.length === 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{risks.map((r) => (
|
||||
<li key={r.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${r.id}`} className="font-bold hover:underline">
|
||||
{riskRef(r.refNo)}
|
||||
</Link>
|
||||
<Link href={`/risks?detail=${r.id}`} className="hover:underline">
|
||||
{r.title}
|
||||
</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(r.score)]}>
|
||||
{r.score} · {tLevel(riskLevel(r.score))}
|
||||
</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bearbeiten im selben Popup — aufgeräumt (Referenz docs/ISMS-GUI-Verbesserungen):
|
||||
* Sprungnavigation, genau EIN Speichern-Button (Stammdaten + BIA über die
|
||||
* HTML-`form`-Association verbunden), Löschen im ⋯-Überlaufmenü. Die
|
||||
* Asset-Zuordnung bleibt inkrementell (Chips add/remove).
|
||||
*/
|
||||
export async function ProcessEditModal({
|
||||
process,
|
||||
users,
|
||||
availableAssets,
|
||||
processes = [],
|
||||
}: {
|
||||
process: ProcessWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
availableAssets: { id: string; name: string }[];
|
||||
/** Auswahl möglicher Hauptprozesse (Haupt-/Teilprozess-Hierarchie). */
|
||||
processes?: { id: string; name: string }[];
|
||||
}) {
|
||||
const t = await getTranslations("processes");
|
||||
const tAssets = await getTranslations("assets");
|
||||
const tRole = await getTranslations("processRole");
|
||||
const tCat = await getTranslations("processCategory");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const bia = process.bia;
|
||||
const FORM_ID = "process-edit-form";
|
||||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const primary = process.processAssets.filter((pa) => pa.role === "PRIMARY");
|
||||
const secondary = process.processAssets.filter((pa) => pa.role === "SECONDARY");
|
||||
|
||||
// Kandidaten für „benötigt": alle anderen Prozesse des Mandanten, noch nicht verknüpft.
|
||||
const linkedTargets = new Set(process.dependsOn.map((d) => d.target.id));
|
||||
const depCandidates = processes.filter((p) => p.id !== process.id && !linkedTargets.has(p.id));
|
||||
|
||||
const assetChip = (pa: ProcessWithDetail["processAssets"][number]) => (
|
||||
<span
|
||||
key={pa.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm"
|
||||
>
|
||||
<span className="font-medium">{pa.asset.name}</span>
|
||||
<CiaBadge c={pa.asset.confidentiality} i={pa.asset.integrity} a={pa.asset.availability} />
|
||||
<form action={unassignAsset.bind(null, process.id, pa.id)} className="flex">
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</span>
|
||||
);
|
||||
|
||||
const jump = [
|
||||
{ href: "#p-grunddaten", label: t("secMaster") },
|
||||
{ href: "#p-assets", label: t("secAssets") },
|
||||
{ href: "#p-deps", label: t("depsSection") },
|
||||
{ href: "#p-bia", label: t("bia") },
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={process.name}
|
||||
headerExtra={
|
||||
<details className="relative">
|
||||
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||||
<MoreHorizontal className="size-4.5" />
|
||||
</summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-52 rounded-xl border bg-card p-1.5">
|
||||
<form action={deleteProcess.bind(null, process.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-4" /> {t("deleteProcess")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
closeHref={`/processes?detail=${process.id}`}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/processes?detail=${process.id}`} />}
|
||||
>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
<Button type="submit" form={FORM_ID}>
|
||||
{tc("save")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* Sprungnavigation */}
|
||||
<div className="sticky top-0 z-10 flex gap-1.5 border-b bg-card/95 px-5 py-2.5 backdrop-blur">
|
||||
{jump.map((j) => (
|
||||
<a
|
||||
key={j.href}
|
||||
href={j.href}
|
||||
className="rounded-full border px-3 py-1 font-heading text-[12px] font-semibold text-muted-foreground hover:bg-secondary hover:text-foreground"
|
||||
>
|
||||
{j.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Die eigentliche <form> ist leer; alle Felder verweisen per form-Attribut
|
||||
hierauf → ein Speichern-Button, egal wo die Felder stehen. */}
|
||||
<form id={FORM_ID} action={saveProcessAll.bind(null, process.id)} className="hidden" />
|
||||
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Grunddaten */}
|
||||
<section id="p-grunddaten" className="scroll-mt-14">
|
||||
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secMaster")}</p>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
form={FORM_ID}
|
||||
required
|
||||
defaultValue={process.name}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="category">{t("category")}</Label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.category}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{(["CORE", "MANAGEMENT", "SUPPORT"] as const).map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tCat(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
form={FORM_ID}
|
||||
rows={2}
|
||||
defaultValue={process.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select
|
||||
id="ownerId"
|
||||
name="ownerId"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.ownerId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="deputyOwnerId">{t("deputyOwner")}</Label>
|
||||
<select
|
||||
id="deputyOwnerId"
|
||||
name="deputyOwnerId"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.deputyOwnerId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{t("noDeputy")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="parentId">{t("parent")}</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
name="parentId"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.parentId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{t("noParent")}</option>
|
||||
{processes
|
||||
.filter((p) => p.id !== process.id)
|
||||
.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="purpose">{t("purpose")}</Label>
|
||||
<Textarea
|
||||
id="purpose"
|
||||
name="purpose"
|
||||
form={FORM_ID}
|
||||
rows={2}
|
||||
defaultValue={process.purpose ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="legalBasis">{t("legalBasis")}</Label>
|
||||
<Input
|
||||
id="legalBasis"
|
||||
name="legalBasis"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.legalBasis ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="interfaces">{t("interfaces")}</Label>
|
||||
<Input
|
||||
id="interfaces"
|
||||
name="interfaces"
|
||||
form={FORM_ID}
|
||||
defaultValue={process.interfaces ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 flex flex-wrap gap-5">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="dataProtectionRelevant"
|
||||
form={FORM_ID}
|
||||
defaultChecked={process.dataProtectionRelevant}
|
||||
className="size-4 rounded border-input"
|
||||
/>
|
||||
{t("dataProtectionRelevant")}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="prototypeRelevant"
|
||||
form={FORM_ID}
|
||||
defaultChecked={process.prototypeRelevant}
|
||||
className="size-4 rounded border-input"
|
||||
/>
|
||||
{t("prototypeRelevant")}
|
||||
</label>
|
||||
</div>
|
||||
{process.catalogCode && (
|
||||
<input type="hidden" name="catalogCode" value={process.catalogCode} form={FORM_ID} />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Assets (inkrementell) */}
|
||||
<section id="p-assets" className="scroll-mt-14 border-t pt-5 text-sm">
|
||||
<p className="mb-3 font-heading text-[15px] font-semibold">{t("secAssets")}</p>
|
||||
<div className="mb-4">
|
||||
<p className="font-medium">{t("primaryAssets")}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("primaryHint")}</p>
|
||||
{primary.length === 0 ? (
|
||||
<p className="text-muted-foreground">{tc("none")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">{primary.map(assetChip)}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t("secondaryAssets")}</p>
|
||||
<p className="mb-2 text-xs text-muted-foreground">{t("secondaryHint")}</p>
|
||||
{secondary.length === 0 ? (
|
||||
<p className="text-muted-foreground">{tc("none")}</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">{secondary.map(assetChip)}</div>
|
||||
)}
|
||||
</div>
|
||||
{availableAssets.length > 0 && (
|
||||
<form action={assignAsset.bind(null, process.id)} className="mt-3 flex flex-wrap gap-2">
|
||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||
{availableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select name="role" defaultValue="SECONDARY" className={selectClass}>
|
||||
<option value="PRIMARY">{tRole("PRIMARY")}</option>
|
||||
<option value="SECONDARY">{tRole("SECONDARY")}</option>
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("assignAsset")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Abhängigkeiten (inkrementell, eigene Forms wie Asset-Zuordnung) */}
|
||||
<section id="p-deps" className="scroll-mt-14 border-t pt-5 text-sm">
|
||||
<p className="mb-1 font-heading text-[15px] font-semibold">{t("depsSection")}</p>
|
||||
<p className="mb-3 text-xs text-muted-foreground">{t("depsRequiresHint")}</p>
|
||||
|
||||
<p className="font-medium">↳ {t("depsRequires")}</p>
|
||||
{process.dependsOn.length === 0 ? (
|
||||
<p className="mt-1 mb-2 text-muted-foreground">{tc("none")}</p>
|
||||
) : (
|
||||
<div className="mt-1.5 mb-3 flex flex-wrap gap-2">
|
||||
{process.dependsOn.map((d) => (
|
||||
<span
|
||||
key={d.id}
|
||||
className="inline-flex items-center gap-2 rounded-full border bg-card py-1 pr-2 pl-3 text-sm"
|
||||
>
|
||||
<span className="font-medium">{d.target.name}</span>
|
||||
{d.note && <span className="text-muted-foreground">· {d.note}</span>}
|
||||
<form action={removeProcessDependency.bind(null, d.id)} className="flex">
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{depCandidates.length > 0 ? (
|
||||
<form
|
||||
action={addProcessDependency.bind(null, process.id)}
|
||||
className="mt-1 flex flex-wrap gap-2"
|
||||
>
|
||||
<select name="targetProcessId" required defaultValue="" className={`${selectClass} flex-1`}>
|
||||
<option value="" disabled>
|
||||
{t("depsSelect")}
|
||||
</option>
|
||||
{depCandidates.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input name="note" placeholder={t("depsNotePlaceholder")} className="flex-1" />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("depsAdd")}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
|
||||
{process.requiredBy.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="font-medium">▲ {t("depsRequiredBy")}</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-2">
|
||||
{process.requiredBy.map((d) => (
|
||||
<Pill key={d.id} tone="info">
|
||||
{d.source.name}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Business Impact Analyse */}
|
||||
<section id="p-bia" className="scroll-mt-14 border-t pt-5 text-sm">
|
||||
<p className="mb-3 font-heading text-[15px] font-semibold">{t("bia")}</p>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["rtoHours", t("rto"), t("rtoLong"), bia?.rtoHours],
|
||||
["rpoHours", t("rpo"), t("rpoLong"), bia?.rpoHours],
|
||||
["mtdHours", t("mtd"), t("mtdLong"), bia?.mtdHours],
|
||||
] as const
|
||||
).map(([name, label, hint, value]) => (
|
||||
<div key={name}>
|
||||
<Label htmlFor={name} title={hint}>
|
||||
{label}
|
||||
</Label>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
form={FORM_ID}
|
||||
type="number"
|
||||
min={0}
|
||||
defaultValue={value ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<fieldset className="mt-4">
|
||||
<legend className="font-medium">{t("impact")}</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["impactC", tAssets("confidentiality"), bia?.impactC],
|
||||
["impactI", tAssets("integrity"), bia?.impactI],
|
||||
["impactA", tAssets("availability"), bia?.impactA],
|
||||
] as const
|
||||
).map(([name, label, value]) => (
|
||||
<div key={name}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={name} defaultValue={value ?? 1} form={FORM_ID} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
name="notes"
|
||||
form={FORM_ID}
|
||||
rows={3}
|
||||
defaultValue={bia?.notes ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { createProject, deleteProject, updateProject } from "@/server/actions/projects";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { projectRef, withQuery } from "@/lib/supplier";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
|
||||
export type ProjectAssetDetail = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
projectProfile: true;
|
||||
owner: { select: { id: true; name: true } };
|
||||
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true; type: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||
processAssets: { include: { process: { select: { id: true; name: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const STATUS_TONE = { GEPLANT: "info", LAUFEND: "warn", ABGESCHLOSSEN: "ok", ABGEBROCHEN: "risk" } as const;
|
||||
const STATUS_VALUES = ["GEPLANT", "LAUFEND", "ABGESCHLOSSEN", "ABGEBROCHEN"] as const;
|
||||
|
||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
||||
|
||||
export async function ProjectDetailModal({ project, canWrite, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; canWrite: boolean; backHref?: string }) {
|
||||
const t = await getTranslations("projects");
|
||||
const ta = await getTranslations("assets");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("projectStatus");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tRisks = await getTranslations("risks");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
const p = project.projectProfile!;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={project.name}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tType(project.type)}</Tag>
|
||||
<Pill tone={STATUS_TONE[p.status]}>{tStatus(p.status)}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref={backHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", project.id)} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="violet">{t("masterPill")}</Pill>
|
||||
</div>
|
||||
<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">
|
||||
<b>{projectRef(p.refNo)} · {project.name}</b>
|
||||
<CiaBadge c={project.confidentiality} i={project.integrity} a={project.availability} labels />
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
<div>{t("owner")}: {project.owner?.name ?? tc("none")}</div>
|
||||
{p.classification && <div>{t("classification")}: {p.classification}</div>}
|
||||
<div>{t("status")}: {tStatus(p.status)}</div>
|
||||
<div>{t("isbInvolved")}: {p.isbInvolved ? tc("yes") : tc("no")}</div>
|
||||
</div>
|
||||
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold">{ta("processes")}</p>
|
||||
{project.processAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{project.processAssets.map((pa) => (
|
||||
<li key={pa.id}>
|
||||
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">{pa.process.name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfte Assets */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{ta("depPill")}</Pill>
|
||||
</div>
|
||||
{project.relationsFrom.length === 0 && project.relationsTo.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
{project.relationsFrom.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{project.relationsFrom.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">
|
||||
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zugeordnete Risiken */}
|
||||
<div className="mx-5 mb-5 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>{t("linkedRisks")}</b>
|
||||
{canWrite && (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${project.id}`} />}>
|
||||
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{project.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{project.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Formular ─────────────────────── */
|
||||
|
||||
async function ProjectFields({ project, users, formId }: { project?: ProjectAssetDetail; users: { id: string; name: string }[]; formId?: string }) {
|
||||
const t = await getTranslations("projects");
|
||||
const tA = await getTranslations("assets");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const tStatus = await getTranslations("projectStatus");
|
||||
const f = formId ? { form: formId } : {};
|
||||
const p = project?.projectProfile;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={project?.name} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select id="ownerId" name="ownerId" defaultValue={project?.ownerId ?? ""} className={`${inputCls} mt-1`} {...f}>
|
||||
<option value="">{t("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>{u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">{t("status")}</Label>
|
||||
<select id="status" name="status" defaultValue={p?.status ?? "GEPLANT"} className={`${inputCls} mt-1`} {...f}>
|
||||
{STATUS_VALUES.map((s) => (
|
||||
<option key={s} value={s}>{tStatus(s)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="classification">{t("classification")}</Label>
|
||||
<Input id="classification" name="classification" defaultValue={p?.classification ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<fieldset className="md:col-span-2">
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", tA("confidentiality")],
|
||||
["integrity", tA("integrity")],
|
||||
["availability", tA("availability")],
|
||||
] as const
|
||||
).map(([n, label]) => (
|
||||
<div key={n}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={n} defaultValue={(project?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="isbInvolved" defaultChecked={p?.isbInvolved} {...f} /> {t("isbInvolved")}
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ProjectCreateModal({ users }: { users: { id: string; name: string }[] }) {
|
||||
const t = await getTranslations("projects");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/assets?type=PROJECT" closeLabel={t("close")}>
|
||||
<form action={createProject} className="space-y-4 p-5">
|
||||
<ProjectFields users={users} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/assets?type=PROJECT" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ProjectEditModal({ project, users, backHref = "/assets?type=PROJECT" }: { project: ProjectAssetDetail; users: { id: string; name: string }[]; backHref?: string }) {
|
||||
const t = await getTranslations("projects");
|
||||
const tc = await getTranslations("common");
|
||||
const FORM = "project-edit";
|
||||
const detailHref = withQuery(backHref, "detail", project.id);
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={project.name}
|
||||
headerExtra={
|
||||
<details className="relative">
|
||||
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
|
||||
<form action={deleteProject.bind(null, project.id, backHref)}>
|
||||
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateProject.bind(null, project.id)} className="hidden">
|
||||
<input type="hidden" name="returnTo" value={backHref} />
|
||||
</form>
|
||||
<div className="p-5">
|
||||
<ProjectFields project={project} users={users} formId={FORM} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { ArrowRight, Check, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addRiskAsset,
|
||||
createRisk,
|
||||
deleteRisk,
|
||||
removeRiskAsset,
|
||||
submitRiskForReview,
|
||||
updateRisk,
|
||||
} from "@/server/actions/risks";
|
||||
import { ObjectReview } from "@/components/object-review";
|
||||
import { adoptStandardMeasure, acceptRisk } from "@/server/actions/risk-catalog";
|
||||
import {
|
||||
createMeasureForRisk,
|
||||
linkMeasureToRisk,
|
||||
unlinkMeasureFromRisk,
|
||||
} from "@/server/actions/measures";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { CiaBadge, CiaLegend, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE, RISK_SCALE_GRADIENT } from "@/lib/risk";
|
||||
import { measureRef, MEASURE_STATUS_TONE } from "@/lib/measure";
|
||||
|
||||
export type RiskWithDetail = Prisma.RiskGetPayload<{
|
||||
include: {
|
||||
owner: { select: { id: true; name: true } };
|
||||
process: { select: { id: true; name: true } };
|
||||
riskAssets: {
|
||||
include: {
|
||||
asset: {
|
||||
select: {
|
||||
id: true;
|
||||
name: true;
|
||||
type: true;
|
||||
confidentiality: true;
|
||||
integrity: true;
|
||||
availability: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
riskMeasures: {
|
||||
include: {
|
||||
measure: { select: { id: true; refNo: true; title: true; status: true } };
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
const SCALE = [1, 2, 3, 4, 5] as const;
|
||||
const TREATMENTS = ["AVOID", "MITIGATE", "TRANSFER", "ACCEPT"] as const;
|
||||
const STATUSES = ["OPEN", "IN_TREATMENT", "ACCEPTED", "CLOSED"] as const;
|
||||
|
||||
const STATUS_TONE = {
|
||||
OPEN: "risk",
|
||||
IN_TREATMENT: "warn",
|
||||
ACCEPTED: "info",
|
||||
CLOSED: "mut",
|
||||
} as const;
|
||||
|
||||
async function ScorePill({ score }: { score: number }) {
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const format = await getFormatter();
|
||||
const level = riskLevel(score);
|
||||
return (
|
||||
<Pill tone={RISK_PILL_TONE[level]}>
|
||||
{format.number(score, { maximumFractionDigits: 2 })} · {tLevel(level)}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bewertungs-Karte: große Score-Zahl, darunter Wahrscheinlichkeit × Schaden + Level-Chip. */
|
||||
async function RatingCard({
|
||||
title,
|
||||
likelihood,
|
||||
impact,
|
||||
score,
|
||||
accent,
|
||||
}: {
|
||||
title: string;
|
||||
likelihood: number | null;
|
||||
impact: number | null;
|
||||
score: number | null;
|
||||
accent: "gross" | "residual";
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const format = await getFormatter();
|
||||
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||||
|
||||
const border = accent === "gross" ? "border-l-[var(--risk)]" : "border-l-[var(--warn)]";
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-l-[3px] bg-[var(--elevated)] p-4 ${border}`}>
|
||||
<div className="text-[11px] font-bold tracking-wide text-muted-foreground uppercase">
|
||||
{title}
|
||||
</div>
|
||||
{score != null && likelihood != null && impact != null ? (
|
||||
<>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="font-heading text-3xl font-bold leading-none text-foreground">
|
||||
{num(score)}
|
||||
</span>
|
||||
<ScorePill score={score} />
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12px] text-muted-foreground">
|
||||
{t("likelihoodShort")} {num(likelihood)} × {t("damageShort")} {num(impact)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-1.5 text-[12px] text-muted-foreground">{t("noMeasures")}</p>
|
||||
)}
|
||||
{accent === "residual" && (
|
||||
<p className="mt-2 text-[11.5px] text-muted-foreground">{t("residualAuto")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Kräftige Farbskala grün→rot mit Markern für Brutto (weiß) und Rest (dunkel). SPEC §4.2. */
|
||||
function RiskScoreScale({ gross, residual }: { gross: number; residual: number | null }) {
|
||||
// 5×5 → Score 1..25; Position linear auf der Skala
|
||||
const pos = (v: number) => `${((Math.min(25, Math.max(1, v)) - 1) / 24) * 100}%`;
|
||||
return (
|
||||
<div className="pt-1">
|
||||
<div
|
||||
className="relative h-3.5 rounded-lg ring-1 ring-white/10"
|
||||
style={{ background: RISK_SCALE_GRADIENT }}
|
||||
>
|
||||
<span
|
||||
className="absolute -top-1.5 h-6.5 w-[4px] -translate-x-1/2 rounded-sm bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.5)]"
|
||||
style={{ left: pos(gross) }}
|
||||
title={`Brutto ${gross}`}
|
||||
/>
|
||||
{residual != null && (
|
||||
<span
|
||||
className="absolute -top-1.5 h-6.5 w-[4px] -translate-x-1/2 rounded-sm bg-[var(--bg-0)] shadow-[0_0_0_1px_rgba(255,255,255,0.7)]"
|
||||
style={{ left: pos(residual) }}
|
||||
title={`Rest ${residual}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-4 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="inline-block h-3 w-[4px] rounded-sm bg-white ring-1 ring-black/40" /> Brutto
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<i className="inline-block h-3 w-[4px] rounded-sm bg-[var(--bg-0)] ring-1 ring-white/60" /> Rest
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Read-only-Risiko-Detail als Popup (SPEC §4.2 Risiko-Detailansicht). */
|
||||
export async function RiskDetailModal({
|
||||
risk,
|
||||
canWrite,
|
||||
}: {
|
||||
risk: RiskWithDetail;
|
||||
canWrite: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tMStatus = await getTranslations("measureStatus");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("detailHeading", { ref: riskRef(risk.refNo), name: risk.title })}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone={STATUS_TONE[risk.status]}>{tStatus(risk.status)}</Pill>
|
||||
<ScorePill score={risk.score} />
|
||||
</span>
|
||||
}
|
||||
closeHref="/risks"
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button
|
||||
variant="outline"
|
||||
nativeButton={false}
|
||||
render={<Link href={`/risks?edit=${risk.id}`} />}
|
||||
>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/risks" />}>
|
||||
{t("close")}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="rounded-xl border border-l-[3px] border-l-[var(--primary)] bg-[var(--surface-soft)] p-4">
|
||||
<b>{risk.title}</b>
|
||||
{risk.description && (
|
||||
<p className="mt-1.5 text-[12.5px] leading-relaxed">{risk.description}</p>
|
||||
)}
|
||||
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
|
||||
<dt className="text-muted-foreground">{t("threat")}</dt>
|
||||
<dd>{risk.threat ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("vulnerability")}</dt>
|
||||
<dd>{risk.vulnerability ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("owner")}</dt>
|
||||
<dd>{risk.owner?.name ?? tc("none")}</dd>
|
||||
<dt className="text-muted-foreground">{t("treatment")}</dt>
|
||||
<dd>
|
||||
<Tag>{tTreat(risk.treatment)}</Tag>
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">{t("process")}</dt>
|
||||
<dd>
|
||||
{risk.process ? (
|
||||
<Link href={`/processes?detail=${risk.process.id}`} className="hover:underline">
|
||||
{risk.process.name}
|
||||
</Link>
|
||||
) : (
|
||||
tc("none")
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{t("affectedAssets")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{t("affectedNote")}</span>
|
||||
</div>
|
||||
{risk.riskAssets.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t("noAssets")}</p>
|
||||
)}
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="pb-1 text-right font-normal">
|
||||
<CiaLegend />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{risk.riskAssets.map((ra) => (
|
||||
<tr key={ra.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">
|
||||
<Link href={`/assets?detail=${ra.asset.id}`} className="hover:underline">
|
||||
{ra.asset.name}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-2">
|
||||
<Tag>{tType(ra.asset.type)}</Tag>
|
||||
</td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge
|
||||
c={ra.asset.confidentiality}
|
||||
i={ra.asset.integrity}
|
||||
a={ra.asset.availability}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Brutto → Rest-Risiko: Farbskala oben, darunter zwei Karten mit Pfeil */}
|
||||
<div className="mx-5 rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
|
||||
<RiskScoreScale gross={risk.score} residual={risk.residualScore} />
|
||||
<div className="mt-4 grid items-center gap-3 sm:grid-cols-[1fr_auto_1fr]">
|
||||
<RatingCard
|
||||
title={t("gross")}
|
||||
likelihood={risk.likelihood}
|
||||
impact={risk.impact}
|
||||
score={risk.score}
|
||||
accent="gross"
|
||||
/>
|
||||
<ArrowRight className="mx-auto size-5 rotate-90 text-muted-foreground sm:rotate-0" />
|
||||
<RatingCard
|
||||
title={t("residual")}
|
||||
likelihood={risk.residualLikelihood}
|
||||
impact={risk.residualImpact}
|
||||
score={risk.residualScore}
|
||||
accent="residual"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notwendige Maßnahmen — echt verknüpft */}
|
||||
<div className="mx-5 mb-5 mt-4">
|
||||
<p className="text-sm font-semibold">{t("measures")}</p>
|
||||
{risk.riskMeasures.length === 0 && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t("noMeasures")}</p>
|
||||
)}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{risk.riskMeasures.map((rm) => (
|
||||
<li key={rm.id} className="flex flex-wrap items-center gap-2">
|
||||
<Link href={`/measures?detail=${rm.measure.id}`} className="font-bold hover:underline">
|
||||
{measureRef(rm.measure.refNo)}
|
||||
</Link>
|
||||
<Link href={`/measures?detail=${rm.measure.id}`} className="hover:underline">
|
||||
{rm.measure.title}
|
||||
</Link>
|
||||
<Pill tone={MEASURE_STATUS_TONE[rm.measure.status]}>
|
||||
{tMStatus(rm.measure.status)}
|
||||
</Pill>
|
||||
{(rm.reductionLikelihood > 0 || rm.reductionImpact > 0) && (
|
||||
<ReductionLabel l={rm.reductionLikelihood} i={rm.reductionImpact} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Generisches Objekt-Review (Story A3-1): Risiko zur Validierung einreichen / validieren. */}
|
||||
<div className="px-5 pb-5">
|
||||
<ObjectReview
|
||||
entityType="risk"
|
||||
entityId={risk.id}
|
||||
entityRef={riskRef(risk.refNo)}
|
||||
title={`Validierung: ${riskRef(risk.refNo)} — ${risk.title}`}
|
||||
submit={submitRiskForReview.bind(null, risk.id)}
|
||||
canSubmit={canWrite}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Standardmaßnahme & Risikoakzeptanz (Story A6-2) — additiv, ergänzt A3-Review. */}
|
||||
{canWrite && (
|
||||
<div className="grid gap-3 px-5 pb-5 md:grid-cols-2">
|
||||
{risk.catalogCode && (
|
||||
<div className="rounded-xl border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[13px] font-semibold">Standardmaßnahme</p>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">Aus dem Katalog ({risk.catalogCode}) eine mit diesem Risiko verknüpfte Maßnahme anlegen — erscheint unten bei den Maßnahmen.</p>
|
||||
<form action={adoptStandardMeasure.bind(null, risk.id)} className="mt-2">
|
||||
<Button type="submit" size="sm" variant="outline"><Plus className="size-3.5" /> Standardmaßnahme übernehmen</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-xl border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[13px] font-semibold">Risikoakzeptanz (VA-09)</p>
|
||||
{risk.acceptanceRationale ? (
|
||||
<p className="mt-1 text-[11.5px] text-muted-foreground"><Check className="mr-1 inline size-3.5 text-[var(--ok)]" />Dokumentiert akzeptiert: {risk.acceptanceRationale}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="mt-0.5 text-[11.5px] text-muted-foreground">
|
||||
{(risk.residualScore ?? risk.score) > 9
|
||||
? "Rest-/Risikowert über der Akzeptanzschwelle — Begründung erforderlich."
|
||||
: "Optional: Akzeptanz dokumentieren."}
|
||||
</p>
|
||||
<form action={acceptRisk.bind(null, risk.id)} className="mt-2 space-y-2">
|
||||
<textarea name="rationale" rows={2} placeholder="Begründung / Managemententscheidung" className="w-full rounded-md border bg-background p-2 text-[12px]" />
|
||||
<Button type="submit" size="sm" variant="outline">Risiko akzeptieren</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bearbeiten als Popup: Bewertung, betroffene Assets, Maßnahmen, Löschen. */
|
||||
export async function RiskEditModal({
|
||||
risk,
|
||||
users,
|
||||
processes,
|
||||
availableAssets,
|
||||
availableMeasures,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
}: {
|
||||
risk: RiskWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
availableAssets: { id: string; name: string }[];
|
||||
availableMeasures: { id: string; refNo: number; title: string }[];
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={`${riskRef(risk.refNo)} · ${risk.title}`}
|
||||
closeHref={`/risks?detail=${risk.id}`}
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
<div className="grid gap-6 p-5 md:grid-cols-2">
|
||||
<form
|
||||
key={risk.updatedAt.toISOString()}
|
||||
action={updateRisk.bind(null, risk.id)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<RiskFields
|
||||
risk={risk}
|
||||
users={users}
|
||||
processes={processes}
|
||||
threats={threats}
|
||||
vulnerabilities={vulnerabilities}
|
||||
selectClass={selectClass}
|
||||
/>
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-5 text-sm">
|
||||
{/* Betroffene Assets */}
|
||||
<div>
|
||||
<p className="font-medium">{t("affectedAssets")}</p>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{t("affectedNote")}</p>
|
||||
{risk.riskAssets.length === 0 && <p>{tc("none")}</p>}
|
||||
<ul className="space-y-1.5">
|
||||
{risk.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
{ra.asset.name}
|
||||
<form action={removeRiskAsset.bind(null, risk.id, ra.id)}>
|
||||
<button
|
||||
type="submit"
|
||||
title={tc("remove")}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{availableAssets.length > 0 && (
|
||||
<form action={addRiskAsset.bind(null, risk.id)} className="mt-2 flex gap-2">
|
||||
<select name="assetId" required className={`${selectClass} flex-1`}>
|
||||
{availableAssets.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("addAsset")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Maßnahmen → bestimmen das Rest-Risiko (volle Breite unter den Stammdaten) */}
|
||||
<div className="border-t p-5 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("measures")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("residualAuto")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{/* Aufklappbare Formulare per <details> — serverseitig, kein Client-State */}
|
||||
{availableMeasures.length > 0 && (
|
||||
<details className="relative">
|
||||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||||
<Plus className="size-4" /> {t("addExistingBtn")}
|
||||
</summary>
|
||||
<form
|
||||
action={linkMeasureToRisk.bind(null, risk.id)}
|
||||
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3"
|
||||
>
|
||||
<p className="text-xs font-semibold">{t("linkMeasure")}</p>
|
||||
<select name="measureId" required className={`${selectClass} w-full`}>
|
||||
{availableMeasures.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{measureRef(m.refNo)} · {m.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ReductionInputs />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{t("linkMeasure")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
<details className="relative">
|
||||
<summary className="inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg border border-border bg-background px-2.5 font-heading text-sm font-semibold select-none hover:bg-muted [&::-webkit-details-marker]:hidden">
|
||||
<Plus className="size-4" /> {t("createNewBtn")}
|
||||
</summary>
|
||||
<form
|
||||
action={createMeasureForRisk.bind(null, risk.id)}
|
||||
className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3"
|
||||
>
|
||||
<p className="text-xs font-semibold">{t("newMeasure")}</p>
|
||||
<Input name="title" required placeholder={t("measureTitle")} />
|
||||
<ReductionInputs />
|
||||
<Button type="submit" variant="secondary" size="sm">
|
||||
{tc("add")}
|
||||
</Button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{risk.riskMeasures.length === 0 && (
|
||||
<p className="mt-3 text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
{risk.riskMeasures.length > 0 && (
|
||||
<div className="mt-3 overflow-hidden rounded-xl border">
|
||||
<div className="grid grid-cols-[7rem_1fr_11rem_11rem_auto_auto] items-center gap-x-3 border-b bg-muted/60 px-3 py-2 text-[11px] font-bold tracking-[.04em] text-muted-foreground uppercase max-md:hidden">
|
||||
<span>{t("id")}</span>
|
||||
<span>{t("measureCol")}</span>
|
||||
<span>{t("reductionL")}</span>
|
||||
<span>{t("reductionI")}</span>
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
{risk.riskMeasures.map((rm) => (
|
||||
<form
|
||||
key={rm.id + "-" + rm.reductionLikelihood + "-" + rm.reductionImpact}
|
||||
action={linkMeasureToRisk.bind(null, risk.id)}
|
||||
className="grid grid-cols-1 items-center gap-2 border-b px-3 py-2.5 last:border-0 md:grid-cols-[7rem_1fr_11rem_11rem_auto_auto] md:gap-x-3"
|
||||
>
|
||||
<input type="hidden" name="measureId" value={rm.measureId} />
|
||||
<b>{measureRef(rm.measure.refNo)}</b>
|
||||
<span className="min-w-0 truncate" title={rm.measure.title}>
|
||||
{rm.measure.title}
|
||||
</span>
|
||||
<Input
|
||||
name="reductionLikelihood"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={rm.reductionLikelihood}
|
||||
aria-label={t("reductionL")}
|
||||
className="h-8"
|
||||
/>
|
||||
<Input
|
||||
name="reductionImpact"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={rm.reductionImpact}
|
||||
aria-label={t("reductionI")}
|
||||
className="h-8"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="icon-sm" title={tc("save")}>
|
||||
<Check className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
title={tc("remove")}
|
||||
formAction={unlinkMeasureFromRisk.bind(null, risk.id, rm.id)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-5">
|
||||
<form action={deleteRisk.bind(null, risk.id)}>
|
||||
<Button type="submit" variant="destructive" size="sm">
|
||||
<Trash2 className="size-4" /> {tc("delete")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anzeige einer Minderung mit lokalisierten Dezimalzahlen. */
|
||||
async function ReductionLabel({ l, i }: { l: number; i: number }) {
|
||||
const t = await getTranslations("risks");
|
||||
const format = await getFormatter();
|
||||
const num = (v: number) => format.number(v, { maximumFractionDigits: 2 });
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("reduction")}: −{num(l)} {t("likelihoodShort")} / −{num(i)} {t("damageShort")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Minderungs-Eingaben (dezimal, 0,00–4,00) für Maßnahmen-Verknüpfungen. */
|
||||
async function ReductionInputs({
|
||||
defaultL = 0,
|
||||
defaultI = 0,
|
||||
}: {
|
||||
defaultL?: number;
|
||||
defaultI?: number;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionL")}</label>
|
||||
<Input
|
||||
name="reductionLikelihood"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={defaultL}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] text-muted-foreground">{t("reductionI")}</label>
|
||||
<Input
|
||||
name="reductionImpact"
|
||||
type="number"
|
||||
min={0}
|
||||
max={4}
|
||||
step={0.05}
|
||||
defaultValue={defaultI}
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularfelder (Anlegen + Bearbeiten teilen sich das Markup). */
|
||||
async function RiskFields({
|
||||
risk,
|
||||
users,
|
||||
processes,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
selectClass,
|
||||
}: {
|
||||
risk?: RiskWithDetail;
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
selectClass: string;
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tTreat = await getTranslations("riskTreatment");
|
||||
const tStatus = await getTranslations("riskStatus");
|
||||
const tc = await getTranslations("common");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="title">{t("titleField")}</Label>
|
||||
<Input id="title" name="title" required defaultValue={risk?.title} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">{t("description")}</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows={2}
|
||||
defaultValue={risk?.description ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="threat">{t("threat")}</Label>
|
||||
<Input
|
||||
id="threat"
|
||||
name="threat"
|
||||
list="threat-catalog"
|
||||
placeholder={t("catalogHint")}
|
||||
defaultValue={risk?.threat ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
<datalist id="threat-catalog">
|
||||
{threats.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="vulnerability">{t("vulnerability")}</Label>
|
||||
<Input
|
||||
id="vulnerability"
|
||||
name="vulnerability"
|
||||
list="vulnerability-catalog"
|
||||
placeholder={t("catalogHint")}
|
||||
defaultValue={risk?.vulnerability ?? ""}
|
||||
className="mt-1"
|
||||
/>
|
||||
<datalist id="vulnerability-catalog">
|
||||
{vulnerabilities.map((name) => (
|
||||
<option key={name} value={name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="likelihood">{t("likelihood")}</Label>
|
||||
<select
|
||||
id="likelihood"
|
||||
name="likelihood"
|
||||
defaultValue={risk?.likelihood ?? 3}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="impact">{t("impact")}</Label>
|
||||
<select
|
||||
id="impact"
|
||||
name="impact"
|
||||
defaultValue={risk?.impact ?? 3}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{SCALE.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{v}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="treatment">{t("treatment")}</Label>
|
||||
<select
|
||||
id="treatment"
|
||||
name="treatment"
|
||||
defaultValue={risk?.treatment ?? "MITIGATE"}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{TREATMENTS.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tTreat(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="status">{t("status")}</Label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
defaultValue={risk?.status ?? "OPEN"}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
{STATUSES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{tStatus(v)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ownerId">{t("owner")}</Label>
|
||||
<select
|
||||
id="ownerId"
|
||||
name="ownerId"
|
||||
defaultValue={risk?.ownerId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="processId">{t("process")}</Label>
|
||||
<select
|
||||
id="processId"
|
||||
name="processId"
|
||||
defaultValue={risk?.processId ?? ""}
|
||||
className={`${selectClass} mt-1 w-full`}
|
||||
>
|
||||
<option value="">{tc("none")}</option>
|
||||
{processes.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Anlegen als Popup — optional mit vorverknüpftem Asset (aus dem Asset-Popup). */
|
||||
export async function RiskCreateModal({
|
||||
users,
|
||||
processes,
|
||||
preselectedAsset,
|
||||
threats,
|
||||
vulnerabilities,
|
||||
}: {
|
||||
users: { id: string; name: string }[];
|
||||
processes: { id: string; name: string }[];
|
||||
preselectedAsset?: { id: string; name: string } | null;
|
||||
threats: string[];
|
||||
vulnerabilities: string[];
|
||||
}) {
|
||||
const t = await getTranslations("risks");
|
||||
const tc = await getTranslations("common");
|
||||
const selectClass = "h-9 rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("createTitle")}
|
||||
sub={preselectedAsset ? `${t("affectedAssets")}: ${preselectedAsset.name}` : undefined}
|
||||
closeHref="/risks"
|
||||
closeLabel={t("close")}
|
||||
>
|
||||
<form action={createRisk} className="space-y-4 p-5">
|
||||
{preselectedAsset && <input type="hidden" name="assetId" value={preselectedAsset.id} />}
|
||||
<RiskFields
|
||||
users={users}
|
||||
processes={processes}
|
||||
threats={threats}
|
||||
vulnerabilities={vulnerabilities}
|
||||
selectClass={selectClass}
|
||||
/>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/risks" />}>
|
||||
{tc("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import {
|
||||
createRole, updateRolePermissions, cloneRole, deleteRole, type RoleFormState,
|
||||
} from "@/server/actions/tenant-users";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
|
||||
export interface ManagedRole { id: string; key: string; name: string; isStandard: boolean; permKeys: string[]; userCount: number }
|
||||
|
||||
const createInit: RoleFormState = { status: "idle" };
|
||||
|
||||
/** Permission-Checkboxen, gruppiert nach Ressourcen-Präfix (asset:*, risk:* …). */
|
||||
function PermissionChecks({ allPermissions, selected, idPrefix }: { allPermissions: string[]; selected: Set<string>; idPrefix: string }) {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const p of allPermissions) {
|
||||
const g = p.split(":")[0];
|
||||
if (!groups.has(g)) groups.set(g, []);
|
||||
groups.get(g)!.push(p);
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{[...groups.entries()].map(([g, perms]) => (
|
||||
<div key={g} className="rounded-lg border bg-muted/40 p-2.5">
|
||||
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{g}</p>
|
||||
<div className="space-y-1">
|
||||
{perms.map((p) => (
|
||||
<label key={p} htmlFor={`${idPrefix}-${p}`} className="flex items-center gap-1.5 text-[12.5px]">
|
||||
<input id={`${idPrefix}-${p}`} type="checkbox" name="perms" value={p} defaultChecked={selected.has(p)} />
|
||||
<code className="font-mono text-[11.5px]">{p}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Anlege-Formular für eigene Rollen. Wird im Popup (?newRole=1) gerendert und
|
||||
* schließt bei Erfolg selbst (Router → closeHref; die Liste ist da revalidiert).
|
||||
*/
|
||||
export function CreateRoleForm({ allPermissions, closeHref }: { allPermissions: string[]; closeHref?: string }) {
|
||||
const [state, action, pending] = useActionState(createRole, createInit);
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
if (state.status === "done" && closeHref) router.push(closeHref);
|
||||
}, [state.status, closeHref, router]);
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<div className="max-w-sm">
|
||||
<Label htmlFor="nr-name">Rollenname *</Label>
|
||||
<Input id="nr-name" name="name" required className="mt-1" placeholder="z. B. Auditor (extern)" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Berechtigungen</Label>
|
||||
<div className="mt-1"><PermissionChecks allPermissions={allPermissions} selected={new Set()} idPrefix="nr" /></div>
|
||||
</div>
|
||||
{state.status === "error" && <p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>}
|
||||
{state.status === "done" && <p className="text-sm text-[var(--ok)]">Rolle angelegt.</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Rolle anlegen"}</Button>
|
||||
{closeHref && <Button type="button" variant="outline" nativeButton={false} render={<Link href={closeHref} />}>Abbrechen</Button>}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aufklappbarer Detailbereich einer Rolle (Berechtigungen ansehen/bearbeiten). */
|
||||
function RoleDetail({ role, allPermissions }: { role: ManagedRole; allPermissions: string[] }) {
|
||||
if (role.isStandard) {
|
||||
return (
|
||||
<div className="border-t p-4">
|
||||
<p className="text-[12px] text-muted-foreground">Standardrollen sind schreibgeschützt. Zum Anpassen klonen und die Kopie bearbeiten.</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{role.permKeys.length === 0
|
||||
? <span className="text-[12px] text-muted-foreground">Keine Berechtigungen.</span>
|
||||
: role.permKeys.map((p) => <code key={p} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px]">{p}</code>)}
|
||||
</div>
|
||||
<form action={cloneRole.bind(null, role.id)} className="mt-3 flex items-end gap-2">
|
||||
<div>
|
||||
<Label htmlFor={`clone-${role.id}`} className="text-[11px]">Name der Kopie</Label>
|
||||
<Input id={`clone-${role.id}`} name="name" className="mt-1 h-8" placeholder={`${role.name} (Kopie)`} />
|
||||
</div>
|
||||
<Button type="submit" variant="outline" size="sm">Klonen</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 border-t p-4">
|
||||
<form action={updateRolePermissions.bind(null, role.id)}>
|
||||
<PermissionChecks allPermissions={allPermissions} selected={new Set(role.permKeys)} idPrefix={`er-${role.id}`} />
|
||||
<Button type="submit" variant="outline" size="sm" className="mt-2">Berechtigungen speichern</Button>
|
||||
</form>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<form action={cloneRole.bind(null, role.id)}><Button type="submit" variant="outline" size="sm">Klonen</Button></form>
|
||||
<form action={deleteRole.bind(null, role.id)}><Button type="submit" variant="outline" size="sm">Löschen</Button></form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Eine Rollenzeile: Klick klappt die Berechtigungen auf/zu. */
|
||||
function RoleRow({ role, allPermissions }: { role: ManagedRole; allPermissions: string[] }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
aria-expanded={open}
|
||||
className="flex w-full items-center justify-between gap-2 p-4 text-left transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronDown className={`size-4 text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`} />
|
||||
<span className="font-medium">{role.name}</span>
|
||||
<code className="font-mono text-[11px] text-muted-foreground">{role.key}</code>
|
||||
{role.isStandard ? <Pill tone="info">Standard</Pill> : <Pill tone="mut">Eigen</Pill>}
|
||||
</div>
|
||||
<span className="text-[11.5px] text-muted-foreground">{role.userCount} Nutzer · {role.permKeys.length} Rechte</span>
|
||||
</button>
|
||||
{open && <RoleDetail role={role} allPermissions={allPermissions} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RoleManager({ roles, allPermissions, newRoleHref }: { roles: ManagedRole[]; allPermissions: string[]; newRoleHref: string }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-end">
|
||||
<Button nativeButton={false} render={<Link href={newRoleHref} />}>
|
||||
<Plus className="size-4" /> Eigene Rolle anlegen
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{roles.map((r) => <RoleRow key={r.id} role={r} allPermissions={allPermissions} />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Ampel-Farblogik Stufe 1–4: Grün → Gelb → Orange → Rot
|
||||
const LEVEL_STYLE: Record<number, { on: string; border: string }> = {
|
||||
1: { on: "bg-[#2e9e6b] text-white border-transparent", border: "border-[#2e9e6b]" },
|
||||
2: { on: "bg-[#e5b000] text-[#3b3b3a] border-transparent", border: "border-[#e5b000]" },
|
||||
3: { on: "bg-[#e07d2e] text-white border-transparent", border: "border-[#e07d2e]" },
|
||||
4: { on: "bg-[#d64c4c] text-white border-transparent", border: "border-[#d64c4c]" },
|
||||
};
|
||||
|
||||
/**
|
||||
* 1–4 anklickbare, farbige Stufen-Buttons mit genau einem aktiven Wert.
|
||||
* Der Wert wird in einem Hidden-Input gehalten, damit die Komponente in
|
||||
* server-gerenderten Formularen (Server Actions) funktioniert.
|
||||
*/
|
||||
export function SegmentedRating({
|
||||
name,
|
||||
defaultValue = 1,
|
||||
low,
|
||||
high,
|
||||
form,
|
||||
}: {
|
||||
name: string;
|
||||
defaultValue?: number;
|
||||
low?: string;
|
||||
high?: string;
|
||||
// HTML form-Association: erlaubt das Feld außerhalb des <form> zu platzieren
|
||||
form?: string;
|
||||
}) {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input type="hidden" name={name} value={value} form={form} />
|
||||
<div className="flex gap-1.5">
|
||||
{[1, 2, 3, 4].map((level) => {
|
||||
const style = LEVEL_STYLE[level];
|
||||
const active = value === level;
|
||||
return (
|
||||
<button
|
||||
key={level}
|
||||
type="button"
|
||||
onClick={() => setValue(level)}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"flex-1 rounded-[9px] border py-2 font-heading text-[13px] font-bold transition-colors",
|
||||
style.border,
|
||||
active ? style.on : "bg-white text-muted-foreground hover:bg-muted/40"
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(low || high) && (
|
||||
<div className="mt-1.5 flex justify-between text-[10.5px] text-muted-foreground">
|
||||
<span>{low}</span>
|
||||
<span>{high}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addRaci,
|
||||
createService,
|
||||
cycleRaci,
|
||||
deleteRaci,
|
||||
deleteService,
|
||||
updateService,
|
||||
} from "@/server/actions/services";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { serviceRef, withQuery } from "@/lib/supplier";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
import { ISA_CONTROLS } from "@/lib/isa-controls";
|
||||
|
||||
export type ServiceAssetDetail = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
serviceProfile: { include: { provider: { select: { id: true; name: true } } } };
|
||||
raci: true;
|
||||
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true; type: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||
processAssets: { include: { process: { select: { id: true; name: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const RACI_TONE = { PROVIDER: "info", US: "violet", SHARED: "warn" } as const;
|
||||
|
||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
||||
|
||||
export async function ServiceDetailModal({ service, canWrite, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; canWrite: boolean; backHref?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const ta = await getTranslations("assets");
|
||||
const tParty = await getTranslations("raciParty");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tRisks = await getTranslations("risks");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
const tp = await getTranslations("processes");
|
||||
const p = service.serviceProfile!;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={service.name}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tType(service.type)}</Tag>
|
||||
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
|
||||
</span>
|
||||
}
|
||||
closeHref={backHref}
|
||||
closeLabel={tp("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", service.id)} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{tp("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten — violette Karte wie beim Asset-Detail */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="violet">{ta("masterPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{ta("masterNote")}</span>
|
||||
</div>
|
||||
<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">
|
||||
<b>{serviceRef(p.refNo)} · {service.name}</b>
|
||||
<CiaBadge c={service.confidentiality} i={service.integrity} a={service.availability} labels />
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
{t("provider")}:{" "}
|
||||
{p.provider ? (
|
||||
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
|
||||
) : (
|
||||
p.internal ? t("internal") : t("noProvider")
|
||||
)}
|
||||
{" · "}
|
||||
{t("criticality")}: {tCrit(String(p.criticality))}
|
||||
</div>
|
||||
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-semibold">{ta("processes")}</p>
|
||||
{service.processAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{service.processAssets.map((pa) => (
|
||||
<li key={pa.id}>
|
||||
<Link href={`/processes?detail=${pa.process.id}`} className="hover:underline">{pa.process.name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfte Assets — Tabelle wie beim Asset-Detail */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{ta("depPill")}</Pill>
|
||||
<span className="text-[12.5px] text-muted-foreground">{ta("depNote")}</span>
|
||||
</div>
|
||||
{service.relationsFrom.length === 0 && service.relationsTo.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
{service.relationsFrom.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="pb-1 text-right font-normal"><CiaLegend /></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{service.relationsFrom.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">
|
||||
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{service.relationsTo.length > 0 && (
|
||||
<>
|
||||
<p className="mt-3 text-[12.5px] text-muted-foreground">{ta("relationReverseHint")}</p>
|
||||
<ul className="mt-1 space-y-1 text-sm">
|
||||
{service.relationsTo.map((rel) => (
|
||||
<li key={rel.id}>
|
||||
<Link href={`/assets?detail=${rel.asset.id}`} className="hover:underline">{rel.asset.name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zugeordnete Risiken — Band wie beim Asset-Detail */}
|
||||
<div className="mx-5 mb-5 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>{ta("linkedRisks")}</b>
|
||||
{canWrite && (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${service.id}`} />}>
|
||||
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{service.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{service.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Verantwortungsmatrix (RACI) — darunter, über die volle Breite */}
|
||||
<div className="mx-5 mb-5">
|
||||
<section className="rounded-xl border">
|
||||
<div className="flex items-center justify-between gap-2 p-4 pb-2">
|
||||
<div>
|
||||
<p className="font-heading text-[15px] font-semibold">{t("raci")}</p>
|
||||
<p className="text-[12px] text-muted-foreground">{t("raciNote")}</p>
|
||||
</div>
|
||||
{canWrite && (
|
||||
<details className="relative">
|
||||
<summary className="bg-grad-soft inline-flex h-8 cursor-pointer list-none items-center gap-1.5 rounded-lg px-2.5 font-heading text-sm font-semibold text-white select-none hover:opacity-90 [&::-webkit-details-marker]:hidden">
|
||||
<Plus className="size-4" /> {t("addControl")}
|
||||
</summary>
|
||||
<form action={addRaci.bind(null, service.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="controlRef" required list="isa-controls" placeholder={t("control")} />
|
||||
<datalist id="isa-controls">
|
||||
{ISA_CONTROLS.map((c) => (
|
||||
<option key={c.ref} value={c.ref}>{c.title}</option>
|
||||
))}
|
||||
</datalist>
|
||||
<Input name="title" placeholder={t("controlTitle")} />
|
||||
<select name="responsibility" className={inputCls}>
|
||||
{(["PROVIDER", "US", "SHARED"] as const).map((r) => (
|
||||
<option key={r} value={r}>{tParty(r)}</option>
|
||||
))}
|
||||
</select>
|
||||
<Input name="evidenceRef" placeholder={t("evidence")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
{service.raci.length === 0 ? (
|
||||
<p className="p-4 pt-0 text-sm text-muted-foreground">{t("raciEmpty")}</p>
|
||||
) : (
|
||||
<div className="overflow-hidden">
|
||||
<div className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-y bg-muted/40 px-4 py-2 text-[10.5px] font-bold tracking-[.04em] text-muted-foreground uppercase">
|
||||
<span>{t("control")}</span>
|
||||
<span>{t("controlTitle")}</span>
|
||||
<span>{t("responsibility")}</span>
|
||||
<span>{t("evidence")}</span>
|
||||
<span />
|
||||
</div>
|
||||
{service.raci.map((r) => {
|
||||
const ctrl = ISA_CONTROLS.find((c) => c.ref === r.controlRef);
|
||||
return (
|
||||
<div key={r.id} className="grid grid-cols-[5rem_1fr_8rem_1fr_auto] items-center gap-x-3 border-b px-4 py-2.5 text-sm last:border-0">
|
||||
<b className={r.applicable ? "" : "opacity-40"}>{r.controlRef}</b>
|
||||
<span className={r.applicable ? "" : "opacity-40"}>{r.title ?? ctrl?.title ?? ""}</span>
|
||||
<span>
|
||||
{canWrite ? (
|
||||
<form action={cycleRaci.bind(null, service.id, r.id)}>
|
||||
<button type="submit" title="Verantwortung wechseln">
|
||||
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<Pill tone={RACI_TONE[r.responsibility]}>{tParty(r.responsibility)}</Pill>
|
||||
)}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">{r.evidenceRef ?? tc("none")}</span>
|
||||
{canWrite && (
|
||||
<form action={deleteRaci.bind(null, service.id, r.id)}>
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><Trash2 className="size-3.5" /></button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Formular ─────────────────────── */
|
||||
|
||||
async function ServiceFields({ service, providers, formId }: { service?: ServiceAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const tA = await getTranslations("assets");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const f = formId ? { form: formId } : {};
|
||||
const p = service?.serviceProfile;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={service?.name} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="providerAssetId">{t("provider")}</Label>
|
||||
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
|
||||
<option value="">{t("noProvider")}</option>
|
||||
{providers.map((pr) => (
|
||||
<option key={pr.id} value={pr.id}>{pr.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("criticality")}</Label>
|
||||
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
|
||||
</div>
|
||||
<fieldset className="md:col-span-2">
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", tA("confidentiality")],
|
||||
["integrity", tA("integrity")],
|
||||
["availability", tA("availability")],
|
||||
] as const
|
||||
).map(([n, label]) => (
|
||||
<div key={n}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={n} defaultValue={(service?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="internal" defaultChecked={p?.internal} {...f} /> {t("internal")}
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ServiceCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
|
||||
const t = await getTranslations("services");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/suppliers?tab=services" closeLabel={t("close")}>
|
||||
<form action={createService} className="space-y-4 p-5">
|
||||
<ServiceFields providers={providers} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=services" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export async function ServiceEditModal({ service, providers, backHref = "/suppliers?tab=services" }: { service: ServiceAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
|
||||
const t = await getTranslations("services");
|
||||
const tc = await getTranslations("common");
|
||||
const FORM = "service-edit";
|
||||
const detailHref = withQuery(backHref, "detail", service.id);
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={service.name}
|
||||
headerExtra={
|
||||
<details className="relative">
|
||||
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
|
||||
<form action={deleteService.bind(null, service.id, backHref)}>
|
||||
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateService.bind(null, service.id)} className="hidden">
|
||||
<input type="hidden" name="returnTo" value={backHref} />
|
||||
</form>
|
||||
<div className="p-5">
|
||||
<ServiceFields service={service} providers={providers} formId={FORM} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Network, Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { createSoftware, deleteSoftware, updateSoftware } from "@/server/actions/software";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { CiaBadge, CiaLegend, CriticalityPill, Pill, Tag } from "@/components/mockup-ui";
|
||||
import { softwareRef, withQuery } from "@/lib/supplier";
|
||||
import { riskLevel, riskRef, RISK_PILL_TONE } from "@/lib/risk";
|
||||
|
||||
export type SoftwareAssetDetail = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
softwareProfile: { include: { provider: { select: { id: true; name: true } } } };
|
||||
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
|
||||
relationsFrom: { include: { relatedAsset: { select: { id: true; name: true; type: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||
relationsTo: { include: { asset: { select: { id: true; name: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
const APPROVAL_TONE = { BEANTRAGT: "warn", FREIGEGEBEN: "ok", GESPERRT: "risk" } as const;
|
||||
const APPROVAL_VALUES = ["BEANTRAGT", "FREIGEGEBEN", "GESPERRT"] as const;
|
||||
|
||||
/* ─────────────────────── Detail-Cockpit ─────────────────────── */
|
||||
|
||||
export async function SoftwareDetailModal({ software, canWrite, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; canWrite: boolean; backHref?: string }) {
|
||||
const t = await getTranslations("software");
|
||||
const ta = await getTranslations("assets");
|
||||
const tCrit = await getTranslations("criticality");
|
||||
const tType = await getTranslations("assetType");
|
||||
const tStatus = await getTranslations("softwareStatus");
|
||||
const tLevel = await getTranslations("riskLevel");
|
||||
const tRisks = await getTranslations("risks");
|
||||
const tDep = await getTranslations("dependencies");
|
||||
const tc = await getTranslations("common");
|
||||
const p = software.softwareProfile!;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={software.name}
|
||||
sub={t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Tag>{tType(software.type)}</Tag>
|
||||
<Pill tone={APPROVAL_TONE[p.approvalStatus]}>{tStatus(p.approvalStatus)}</Pill>
|
||||
<CriticalityPill level={p.criticality} label={tCrit(String(p.criticality))} />
|
||||
</span>
|
||||
}
|
||||
closeHref={backHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={withQuery(backHref, "edit", software.id)} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-5 p-5 md:grid-cols-2">
|
||||
{/* Stammdaten */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="violet">{t("masterPill")}</Pill>
|
||||
</div>
|
||||
<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">
|
||||
<b>{softwareRef(p.refNo)} · {software.name}</b>
|
||||
<CiaBadge c={software.confidentiality} i={software.integrity} a={software.availability} labels />
|
||||
</div>
|
||||
<div className="mt-1.5 space-y-1 text-[12.5px] leading-relaxed text-muted-foreground">
|
||||
<div>
|
||||
{t("provider")}:{" "}
|
||||
{p.provider ? (
|
||||
<Link href={`/suppliers?detail=${p.provider.id}`} className="hover:underline">{p.provider.name}</Link>
|
||||
) : t("noProvider")}
|
||||
</div>
|
||||
{p.version && <div>{t("version")}: {p.version}</div>}
|
||||
<div>{t("approvalStatus")}: {tStatus(p.approvalStatus)}{p.approvedBy ? ` · ${t("approvedBy")}: ${p.approvedBy}` : ""}</div>
|
||||
<div>{t("criticality")}: {tCrit(String(p.criticality))}</div>
|
||||
{p.nextReview && <div>{t("nextReview")}: {p.nextReview.toISOString().slice(0, 10)}</div>}
|
||||
</div>
|
||||
{p.notes && <p className="mt-2 text-[12.5px] leading-relaxed">{p.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verknüpfte Assets */}
|
||||
<div>
|
||||
<div className="mb-2.5 flex items-center gap-2.5">
|
||||
<Pill tone="info">{ta("depPill")}</Pill>
|
||||
</div>
|
||||
{software.relationsFrom.length === 0 && software.relationsTo.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{tc("none")}</p>
|
||||
)}
|
||||
{software.relationsFrom.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr><th /><th /><th className="pb-1 text-right font-normal"><CiaLegend /></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{software.relationsFrom.map((rel) => (
|
||||
<tr key={rel.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 pr-2 font-bold">
|
||||
<Link href={`/assets?detail=${rel.relatedAsset.id}`} className="hover:underline">{rel.relatedAsset.name}</Link>
|
||||
</td>
|
||||
<td className="py-2.5 pr-2"><Tag>{tType(rel.relatedAsset.type)}</Tag></td>
|
||||
<td className="py-2.5 text-right">
|
||||
<CiaBadge c={rel.relatedAsset.confidentiality} i={rel.relatedAsset.integrity} a={rel.relatedAsset.availability} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Link href="/dependencies" className="mt-3 inline-flex items-center gap-1.5 text-[12.5px] font-semibold text-[var(--info)] hover:underline">
|
||||
<Network className="size-3.5" /> {tDep("openGraph")} →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zugeordnete Risiken */}
|
||||
<div className="mx-5 mb-5 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>{t("linkedRisks")}</b>
|
||||
{canWrite && (
|
||||
<Button variant="outline" size="sm" nativeButton={false} render={<Link href={`/risks?new=1&asset=${software.id}`} />}>
|
||||
<Plus className="size-3.5" /> {tRisks("createFromAsset")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{software.riskAssets.length === 0 && <p className="mt-1">{tc("none")}</p>}
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{software.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">{riskRef(ra.risk.refNo)}</Link>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
|
||||
<Pill tone={RISK_PILL_TONE[riskLevel(ra.risk.score)]}>{ra.risk.score} · {tLevel(riskLevel(ra.risk.score))}</Pill>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Formular ─────────────────────── */
|
||||
|
||||
async function SoftwareFields({ software, providers, formId }: { software?: SoftwareAssetDetail; providers: { id: string; name: string }[]; formId?: string }) {
|
||||
const t = await getTranslations("software");
|
||||
const tA = await getTranslations("assets");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const tStatus = await getTranslations("softwareStatus");
|
||||
const f = formId ? { form: formId } : {};
|
||||
const p = software?.softwareProfile;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={software?.name} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="providerAssetId">{t("provider")}</Label>
|
||||
<select id="providerAssetId" name="providerAssetId" defaultValue={p?.providerAssetId ?? ""} className={`${inputCls} mt-1`} {...f}>
|
||||
<option value="">{t("noProvider")}</option>
|
||||
{providers.map((pr) => (
|
||||
<option key={pr.id} value={pr.id}>{pr.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="version">{t("version")}</Label>
|
||||
<Input id="version" name="version" defaultValue={p?.version ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="approvalStatus">{t("approvalStatus")}</Label>
|
||||
<select id="approvalStatus" name="approvalStatus" defaultValue={p?.approvalStatus ?? "BEANTRAGT"} className={`${inputCls} mt-1`} {...f}>
|
||||
{APPROVAL_VALUES.map((s) => (
|
||||
<option key={s} value={s}>{tStatus(s)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="approvedBy">{t("approvedBy")}</Label>
|
||||
<Input id="approvedBy" name="approvedBy" defaultValue={p?.approvedBy ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("criticality")}</Label>
|
||||
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="nextReview">{t("nextReview")}</Label>
|
||||
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<fieldset className="md:col-span-2">
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", tA("confidentiality")],
|
||||
["integrity", tA("integrity")],
|
||||
["availability", tA("availability")],
|
||||
] as const
|
||||
).map(([n, label]) => (
|
||||
<div key={n}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={n} defaultValue={(software?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function SoftwareCreateModal({ providers }: { providers: { id: string; name: string }[] }) {
|
||||
const t = await getTranslations("software");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/suppliers?tab=software" closeLabel={t("close")}>
|
||||
<form action={createSoftware} className="space-y-4 p-5">
|
||||
<SoftwareFields providers={providers} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers?tab=software" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export async function SoftwareEditModal({ software, providers, backHref = "/suppliers?tab=software" }: { software: SoftwareAssetDetail; providers: { id: string; name: string }[]; backHref?: string }) {
|
||||
const t = await getTranslations("software");
|
||||
const tc = await getTranslations("common");
|
||||
const FORM = "software-edit";
|
||||
const detailHref = withQuery(backHref, "detail", software.id);
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={software.name}
|
||||
headerExtra={
|
||||
<details className="relative">
|
||||
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
|
||||
<form action={deleteSoftware.bind(null, software.id, backHref)}>
|
||||
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateSoftware.bind(null, software.id)} className="hidden">
|
||||
<input type="hidden" name="returnTo" value={backHref} />
|
||||
</form>
|
||||
<div className="p-5">
|
||||
<SoftwareFields software={software} providers={providers} formId={FORM} />
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Check, X, AlertTriangle, ShieldCheck } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PROTECTION_LABEL,
|
||||
type ProtectionLevel,
|
||||
type ReqContext,
|
||||
type ReqTheme,
|
||||
type Tier,
|
||||
} from "@/lib/supplier";
|
||||
|
||||
type SerReq = { key: string; label: string; control: string; tier: Tier; theme: ReqTheme; met: boolean };
|
||||
|
||||
const TIER_MIN: Record<Tier, number> = { must: 1, should: 1, high: 2, veryhigh: 3 };
|
||||
const THEME_LABEL: Record<ReqTheme, string> = {
|
||||
contract: "Vertrag & Geheimhaltung",
|
||||
assessment: "Bewertung & Nachweise",
|
||||
transparency: "Transparenz (sehr hoher Schutzbedarf)",
|
||||
};
|
||||
|
||||
export function SupplierRequirements({
|
||||
reqs,
|
||||
ctx,
|
||||
actualLevel,
|
||||
maxCia,
|
||||
linkedAssets,
|
||||
computedMaturity,
|
||||
isbValue,
|
||||
targetMaturity,
|
||||
gateEvidenceHref,
|
||||
gateDecisionHref,
|
||||
createRiskAction,
|
||||
}: {
|
||||
reqs: SerReq[];
|
||||
ctx: ReqContext;
|
||||
actualLevel: ProtectionLevel;
|
||||
maxCia: number;
|
||||
linkedAssets: { name: string; c: number; i: number; a: number }[];
|
||||
computedMaturity: number;
|
||||
isbValue: number | null;
|
||||
targetMaturity: number;
|
||||
gateEvidenceHref: string;
|
||||
gateDecisionHref: string;
|
||||
createRiskAction: () => Promise<void>;
|
||||
}) {
|
||||
const [sim, setSim] = useState<ProtectionLevel>(actualLevel);
|
||||
|
||||
const status = (r: SerReq): "met" | "open" | "na" =>
|
||||
sim < TIER_MIN[r.tier] ? "na" : r.met ? "met" : "open";
|
||||
|
||||
const gateActive = sim === 3 && !ctx.hasAudit;
|
||||
const gateSatisfied = ctx.hasAudit || (ctx.hasManagementDecision && ctx.hasLinkedRisk);
|
||||
|
||||
const themes: ReqTheme[] = ["contract", "assessment", "transparency"];
|
||||
|
||||
const maturity = isbValue ?? computedMaturity;
|
||||
const maturityOk = maturity >= targetMaturity;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Schutzbedarf-Banner mit Simulation */}
|
||||
<div className="rounded-xl border border-[var(--panel-brd)] bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="font-heading text-[14px] font-semibold">Schutzbedarf steuert die Anforderungen</p>
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Verknüpfte Assets:{" "}
|
||||
{linkedAssets.length
|
||||
? linkedAssets.map((a) => `${a.name} (${a.c}/${a.i}/${a.a})`).join(" · ")
|
||||
: "—"}{" "}
|
||||
· abgeleitet: <b className="text-foreground">{PROTECTION_LABEL[actualLevel]}</b> (max C/I/A {maxCia})
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="text-muted-foreground">Simulieren:</span>
|
||||
{([1, 2, 3] as ProtectionLevel[]).map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
onClick={() => setSim(lvl)}
|
||||
className={cn(
|
||||
"rounded-lg border px-2.5 py-1 font-heading font-semibold",
|
||||
sim === lvl
|
||||
? lvl === 3
|
||||
? "border-transparent bg-[var(--risk)] text-white"
|
||||
: "border-transparent bg-[var(--primary)] text-white"
|
||||
: "border-[var(--panel-brd)] bg-[var(--elevated)] text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{PROTECTION_LABEL[lvl]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flache Anforderungsliste, nach Themen gruppiert */}
|
||||
<div className="rounded-xl border p-4">
|
||||
<p className="font-heading text-[15px] font-semibold">Anforderungen</p>
|
||||
<p className="mb-2 text-[12px] text-muted-foreground">
|
||||
Nur die tatsächlich relevanten Anforderungen — mit Referenz und Herkunfts-Control.
|
||||
</p>
|
||||
{themes.map((theme) => {
|
||||
const rows = reqs.filter((r) => r.theme === theme);
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div key={theme} className="mt-3">
|
||||
<p className="text-[10.5px] font-bold tracking-[.05em] text-muted-foreground uppercase">
|
||||
{THEME_LABEL[theme]}
|
||||
</p>
|
||||
<ul className="mt-1">
|
||||
{rows.map((r) => {
|
||||
const st = status(r);
|
||||
return (
|
||||
<li
|
||||
key={r.key}
|
||||
className={cn(
|
||||
"flex items-center gap-2.5 border-b border-[var(--panel-brd)] py-2 text-[13px] last:border-0",
|
||||
st === "na" && "opacity-40"
|
||||
)}
|
||||
>
|
||||
{st === "met" ? (
|
||||
<Check className="size-4 shrink-0 text-[var(--ok)]" />
|
||||
) : st === "open" ? (
|
||||
<X className="size-4 shrink-0 text-[var(--risk)]" />
|
||||
) : (
|
||||
<span className="size-4 shrink-0 rounded-full border border-dashed border-muted-foreground/40" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1">{r.label}</span>
|
||||
{st === "open" && (
|
||||
<Link
|
||||
href={r.theme === "assessment" ? gateEvidenceHref : gateDecisionHref}
|
||||
className="text-[11.5px] font-semibold text-[var(--info)] hover:underline"
|
||||
>
|
||||
+ verknüpfen
|
||||
</Link>
|
||||
)}
|
||||
<span className="text-[10.5px] font-bold text-muted-foreground/70">{r.control}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Gate-Panel */}
|
||||
{gateActive && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-xl border p-4",
|
||||
gateSatisfied
|
||||
? "border-[rgba(57,192,127,0.4)] bg-[rgba(57,192,127,0.1)]"
|
||||
: "border-[rgba(255,107,107,0.4)] bg-[rgba(255,107,107,0.1)]"
|
||||
)}
|
||||
>
|
||||
<p className="flex items-center gap-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||
<AlertTriangle className="size-4" /> Sehr hoher Schutzbedarf — Kompensation erforderlich
|
||||
</p>
|
||||
<p className="mt-1 text-[12px] text-muted-foreground">
|
||||
Kein gültiges Third-Party-Audit / TISAX-Label. Nachweis über eine der Optionen:
|
||||
</p>
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
|
||||
<p className="text-[12px] font-semibold">Option A · Audit-Nachweis</p>
|
||||
<Link
|
||||
href={gateEvidenceHref}
|
||||
className="mt-2 inline-block rounded-lg bg-[var(--primary)] px-2.5 py-1.5 text-[12px] font-semibold text-white hover:opacity-90"
|
||||
>
|
||||
TISAX-Label / Audit erfassen
|
||||
</Link>
|
||||
<p className="mt-2 text-[11px] text-muted-foreground">oder selbst durchgeführtes Lieferanten-Audit</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--panel-brd)] p-3">
|
||||
<p className="text-[12px] font-semibold">Option B · Kompensation (beides nötig)</p>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2 text-[12px]">
|
||||
<span className={ctx.hasManagementDecision ? "text-[var(--ok)]" : ""}>Managemententscheidung</span>
|
||||
<Link href={gateDecisionHref} className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
|
||||
Dokumentieren
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2 text-[12px]">
|
||||
<span className={ctx.hasLinkedRisk ? "text-[var(--ok)]" : ""}>Risiko im Risikomodul</span>
|
||||
<form action={createRiskAction}>
|
||||
<button type="submit" className="rounded-md border border-[var(--panel-brd)] px-2 py-1 text-[11.5px] font-semibold hover:bg-[var(--accent)]">
|
||||
Risiko anlegen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reifegrad-/Konformitätskarte */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ShieldCheck className={cn("size-6", maturityOk ? "text-[var(--ok)]" : "text-[var(--warn)]")} />
|
||||
<div>
|
||||
<p className="text-[12px] text-muted-foreground">Reifegrad</p>
|
||||
<p className="font-heading text-2xl font-bold leading-none">
|
||||
{maturity.toFixed(1)} <span className="text-[13px] font-normal text-muted-foreground">/ {targetMaturity.toFixed(1)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11.5px] text-muted-foreground">
|
||||
{isbValue != null ? "ISB-freigegeben" : `berechnet: ${computedMaturity.toFixed(1)}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
addAssessment,
|
||||
addContract,
|
||||
addDecision,
|
||||
addEvidence,
|
||||
addNda,
|
||||
approveMaturity,
|
||||
createGateRisk,
|
||||
createSupplier,
|
||||
deleteChild,
|
||||
deleteSupplier,
|
||||
updateSupplier,
|
||||
} from "@/server/actions/suppliers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { SegmentedRating } from "@/components/segmented-rating";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { SupplierRequirements } from "@/components/supplier-cockpit";
|
||||
import {
|
||||
supplierRef,
|
||||
withQuery,
|
||||
SUPPLIER_LIFECYCLE_TONE,
|
||||
protectionLevel,
|
||||
PROTECTION_LABEL,
|
||||
buildReqContext,
|
||||
REQUIREMENTS,
|
||||
conformity,
|
||||
computedMaturity,
|
||||
CONFORMITY_TONE,
|
||||
CONFORMITY_LABEL,
|
||||
TARGET_MATURITY,
|
||||
isExpired,
|
||||
isExpiring,
|
||||
} from "@/lib/supplier";
|
||||
|
||||
export type SupplierAssetDetail = Prisma.AssetGetPayload<{
|
||||
include: {
|
||||
supplierProfile: true;
|
||||
contracts: true;
|
||||
ndas: true;
|
||||
evidence: true;
|
||||
assessments: true;
|
||||
subcontractors: true;
|
||||
decisions: true;
|
||||
maturity: true;
|
||||
riskAssets: { include: { risk: { select: { id: true; refNo: true; title: true; score: true } } } };
|
||||
relationsFrom: { include: { relatedAsset: { select: { name: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||
relationsTo: { include: { asset: { select: { name: true; confidentiality: true; integrity: true; availability: true } } } };
|
||||
};
|
||||
}>;
|
||||
|
||||
const inputCls = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
function contextOf(s: SupplierAssetDetail) {
|
||||
const linkedAssets = [
|
||||
{ name: s.name, c: s.confidentiality, i: s.integrity, a: s.availability },
|
||||
...s.relationsFrom.map((r) => ({ name: r.relatedAsset.name, c: r.relatedAsset.confidentiality, i: r.relatedAsset.integrity, a: r.relatedAsset.availability })),
|
||||
...s.relationsTo.map((r) => ({ name: r.asset.name, c: r.asset.confidentiality, i: r.asset.integrity, a: r.asset.availability })),
|
||||
];
|
||||
const maxCia = Math.max(...linkedAssets.map((a) => Math.max(a.c, a.i, a.a)));
|
||||
const level = protectionLevel(maxCia);
|
||||
const ctx = buildReqContext({
|
||||
contracts: s.contracts,
|
||||
ndas: s.ndas,
|
||||
evidence: s.evidence,
|
||||
assessments: s.assessments,
|
||||
subcontractors: s.subcontractors,
|
||||
nextReview: s.supplierProfile?.nextReview ?? null,
|
||||
linkedRiskCount: s.riskAssets.length,
|
||||
decisionCount: s.decisions.length,
|
||||
});
|
||||
return { linkedAssets: linkedAssets.slice(1), maxCia, level, ctx };
|
||||
}
|
||||
|
||||
/* ─────────────────────── Cockpit (Detail) ─────────────────────── */
|
||||
|
||||
export async function SupplierDetailModal({ supplier, canWrite, backHref = "/suppliers" }: { supplier: SupplierAssetDetail; canWrite: boolean; backHref?: string }) {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tLife = await getTranslations("supplierStatus");
|
||||
const tEKind = await getTranslations("evidenceKind");
|
||||
const tc = await getTranslations("common");
|
||||
const fmt = await getFormatter();
|
||||
const date = (d: Date | null) => (d ? fmt.dateTime(d, { dateStyle: "medium" }) : tc("none"));
|
||||
|
||||
const p = supplier.supplierProfile!;
|
||||
const { linkedAssets, maxCia, level, ctx } = contextOf(supplier);
|
||||
const conf = conformity(level, ctx);
|
||||
const computed = computedMaturity(level, ctx);
|
||||
const reqs = REQUIREMENTS.map((r) => ({ key: r.key, label: r.label, control: r.control, tier: r.tier, theme: r.theme, met: r.met(ctx) }));
|
||||
|
||||
const editHref = withQuery(backHref, "edit", supplier.id);
|
||||
const gateEvidenceHref = editHref;
|
||||
const gateDecisionHref = editHref;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={`${supplierRef(p.refNo)} · ${supplier.name}`}
|
||||
sub={p.serviceDesc ?? t("detailSub")}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
{p.nis2Relevant && <Pill tone="info">NIS2</Pill>}
|
||||
<Pill tone={level === 3 ? "risk" : level === 2 ? "warn" : "ok"}>Schutzbedarf {PROTECTION_LABEL[level]}</Pill>
|
||||
<Pill tone={CONFORMITY_TONE[conf]}>{CONFORMITY_LABEL[conf]}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref={backHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
{canWrite && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={editHref} />}>
|
||||
<Pencil className="size-4" /> {tc("edit")}
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href={backHref} />}>{t("close")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-5 p-5">
|
||||
<SupplierRequirements
|
||||
reqs={reqs}
|
||||
ctx={ctx}
|
||||
actualLevel={level}
|
||||
maxCia={maxCia}
|
||||
linkedAssets={linkedAssets}
|
||||
computedMaturity={computed}
|
||||
isbValue={supplier.maturity?.isbValue ?? null}
|
||||
targetMaturity={TARGET_MATURITY}
|
||||
gateEvidenceHref={gateEvidenceHref}
|
||||
gateDecisionHref={gateDecisionHref}
|
||||
createRiskAction={createGateRisk.bind(null, supplier.id)}
|
||||
/>
|
||||
|
||||
{/* Risiken */}
|
||||
<section>
|
||||
<p className="text-sm font-semibold">{t("risks")}</p>
|
||||
{supplier.riskAssets.length === 0 && <p className="mt-1 text-sm text-muted-foreground">{tc("none")}</p>}
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{supplier.riskAssets.map((ra) => (
|
||||
<li key={ra.id} className="flex items-center gap-2">
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="font-bold hover:underline">R-{String(ra.risk.refNo).padStart(3, "0")}</Link>
|
||||
<Link href={`/risks?detail=${ra.risk.id}`} className="hover:underline">{ra.risk.title}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Verträge & NDA & Nachweise (read) */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<section>
|
||||
<p className="text-sm font-semibold">{t("contracts")} & {t("ndas")}</p>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{supplier.contracts.map((c) => (
|
||||
<li key={c.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{c.reference ?? c.type}</span>
|
||||
{c.avDpa && <Pill tone="ok">AV/DPA</Pill>}
|
||||
{c.validTo && <span className={isExpiring(c.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(c.validTo)}</span>}
|
||||
</li>
|
||||
))}
|
||||
{supplier.ndas.map((n) => (
|
||||
<li key={n.id} className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">NDA: {n.subject ?? n.parties ?? ""}</span>
|
||||
{n.validTo && <span className={isExpiring(n.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("validTo")}: {date(n.validTo)}</span>}
|
||||
</li>
|
||||
))}
|
||||
{supplier.contracts.length + supplier.ndas.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<p className="text-sm font-semibold">{t("evidence")}</p>
|
||||
<ul className="mt-1.5 space-y-1.5 text-sm">
|
||||
{supplier.evidence.map((e) => (
|
||||
<li key={e.id} className="flex flex-wrap items-center gap-2">
|
||||
<Pill tone="violet">{tEKind(e.kind)}</Pill>
|
||||
<span>{e.name ?? tEKind(e.kind)}</span>
|
||||
{e.adequacyChecked && <Pill tone="ok">{t("adequacy")}</Pill>}
|
||||
{e.validTo && <span className={isExpired(e.validTo) ? "text-[var(--risk)]" : isExpiring(e.validTo) ? "text-[var(--warn)]" : "text-muted-foreground"}>{t("expires")} {date(e.validTo)}</span>}
|
||||
</li>
|
||||
))}
|
||||
{supplier.evidence.length === 0 && <li className="text-muted-foreground">{tc("none")}</li>}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* ISB-Freigabe */}
|
||||
{canWrite && (
|
||||
<section className="rounded-xl border border-[var(--band-brd)] bg-[var(--band)] p-4">
|
||||
<p className="text-sm font-semibold">{t("isbApproval")}</p>
|
||||
<form action={approveMaturity.bind(null, supplier.id, computed)} className="mt-2 flex flex-wrap items-end gap-3 text-sm">
|
||||
<div>
|
||||
<Label htmlFor="isbValue">{t("isbValue")} (0–3)</Label>
|
||||
<Input id="isbValue" name="isbValue" type="number" min={0} max={3} step={0.1} defaultValue={supplier.maturity?.isbValue ?? computed} className="mt-1 w-28" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="justification">{t("justification")}</Label>
|
||||
<Input id="justification" name="justification" defaultValue={supplier.maturity?.isbJustification ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<Button type="submit">{t("approve")}</Button>
|
||||
</form>
|
||||
<p className="mt-1.5 text-[11px] text-muted-foreground">{t("approvalNote")}</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Lifecycle + Datenkategorien */}
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{t("status")}: <Pill tone={SUPPLIER_LIFECYCLE_TONE[p.lifecycle]}>{tLife(p.lifecycle)}</Pill>
|
||||
{p.dataCategories.length > 0 && ` · ${t("dataCategories")}: ${p.dataCategories.join(", ")}`}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Stammdaten-Formular ─────────────────────── */
|
||||
|
||||
async function SupplierFields({ supplier, formId }: { supplier?: SupplierAssetDetail; formId?: string }) {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tLife = await getTranslations("supplierStatus");
|
||||
const tA = await getTranslations("assets");
|
||||
const tLevel = await getTranslations("protectionLevel");
|
||||
const f = formId ? { form: formId } : {};
|
||||
const p = supplier?.supplierProfile;
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input id="name" name="name" required defaultValue={supplier?.name} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sector">{t("sector")}</Label>
|
||||
<Input id="sector" name="sector" defaultValue={p?.sector ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="contact">{t("contact")}</Label>
|
||||
<Input id="contact" name="contact" defaultValue={p?.contact ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="serviceDesc">{t("services")}</Label>
|
||||
<Textarea id="serviceDesc" name="serviceDesc" rows={2} defaultValue={p?.serviceDesc ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lifecycle">{t("status")}</Label>
|
||||
<select id="lifecycle" name="lifecycle" defaultValue={p?.lifecycle ?? "ACTIVE"} className={`${inputCls} mt-1`} {...f}>
|
||||
{(["ACTIVE", "ONBOARDING", "UNDER_REVIEW", "OFFBOARDED"] as const).map((v) => (
|
||||
<option key={v} value={v}>{tLife(v)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t("criticality")}</Label>
|
||||
<div className="mt-1"><SegmentedRating name="criticality" defaultValue={p?.criticality ?? 1} form={formId} /></div>
|
||||
</div>
|
||||
<fieldset className="md:col-span-2">
|
||||
<legend className="text-sm font-medium">{t("protection")} (1–4)</legend>
|
||||
<div className="mt-2 grid grid-cols-3 gap-4">
|
||||
{(
|
||||
[
|
||||
["confidentiality", tA("confidentiality")],
|
||||
["integrity", tA("integrity")],
|
||||
["availability", tA("availability")],
|
||||
] as const
|
||||
).map(([n, label]) => (
|
||||
<div key={n}>
|
||||
<Label>{label}</Label>
|
||||
<div className="mt-1">
|
||||
<SegmentedRating name={n} defaultValue={(supplier?.[n] as number) ?? 1} low={tLevel("1")} high={tLevel("4")} form={formId} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div>
|
||||
<Label htmlFor="nextReview">{t("nextReview")}</Label>
|
||||
<Input id="nextReview" name="nextReview" type="date" defaultValue={p?.nextReview ? p.nextReview.toISOString().slice(0, 10) : ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="dataCategories">{t("dataCategories")}</Label>
|
||||
<Input id="dataCategories" name="dataCategories" defaultValue={p?.dataCategories.join(", ") ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm md:col-span-2">
|
||||
<input type="checkbox" name="nis2Relevant" defaultChecked={p?.nis2Relevant} {...f} /> {t("nis2")}
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Label htmlFor="notes">{t("notes")}</Label>
|
||||
<Textarea id="notes" name="notes" rows={2} defaultValue={p?.notes ?? ""} className="mt-1" {...f} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export async function SupplierCreateModal() {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<Modal title={t("createTitle")} closeHref="/suppliers" closeLabel={t("close")}>
|
||||
<form action={createSupplier} className="space-y-4 p-5">
|
||||
<SupplierFields />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">{tc("save")}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/suppliers" />}>{tc("cancel")}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────── Bearbeiten ─────────────────────── */
|
||||
|
||||
export async function SupplierEditModal({ supplier, backHref = "/suppliers" }: { supplier: SupplierAssetDetail; backHref?: string }) {
|
||||
const t = await getTranslations("suppliers");
|
||||
const tc = await getTranslations("common");
|
||||
const tEKind = await getTranslations("evidenceKind");
|
||||
const tAType = await getTranslations("assessmentType");
|
||||
const FORM = "supplier-edit";
|
||||
const detailHref = withQuery(backHref, "detail", supplier.id);
|
||||
const disc =
|
||||
"cursor-pointer list-none rounded-lg border border-[var(--panel-brd)] bg-[var(--elevated)] px-3 py-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t("editTitle")}
|
||||
sub={supplier.name}
|
||||
headerExtra={
|
||||
<details className="relative">
|
||||
<summary className="grid size-8 cursor-pointer list-none place-items-center rounded-md text-muted-foreground hover:bg-muted [&::-webkit-details-marker]:hidden"><Trash2 className="size-4" /></summary>
|
||||
<div className="shadow-card absolute right-0 z-20 mt-1 w-48 rounded-xl border bg-card p-1.5">
|
||||
<form action={deleteSupplier.bind(null, supplier.id, backHref)}>
|
||||
<button type="submit" className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm text-destructive hover:bg-destructive/10"><Trash2 className="size-4" /> {tc("delete")}</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
closeHref={detailHref}
|
||||
closeLabel={t("close")}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={detailHref} />}>{tc("cancel")}</Button>
|
||||
<Button type="submit" form={FORM}>{tc("save")}</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id={FORM} action={updateSupplier.bind(null, supplier.id)} className="hidden">
|
||||
<input type="hidden" name="returnTo" value={backHref} />
|
||||
</form>
|
||||
<div className="space-y-6 p-5">
|
||||
<SupplierFields supplier={supplier} formId={FORM} />
|
||||
|
||||
<ChildSection title={`${t("evidence")}`} items={supplier.evidence.map((e) => ({ id: e.id, label: `${tEKind(e.kind)} · ${e.name ?? ""}${e.adequacyChecked ? " ✓" : ""}` }))} kind="evidence" addLabel={t("addEvidence")} disc={disc}>
|
||||
<form action={addEvidence.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<select name="kind" className={inputCls}>{(["TISAX_LABEL", "AUDIT_REPORT", "CERTIFICATE", "ATTESTATION", "SELF_ASSESSMENT"] as const).map((k) => <option key={k} value={k}>{tEKind(k)}</option>)}</select>
|
||||
<Input name="name" placeholder={t("name")} />
|
||||
<Input name="protectsCia" placeholder={t("protectsCia")} defaultValue="C,I,A" />
|
||||
<Input name="validTo" type="date" />
|
||||
<label className="flex items-center gap-2 text-[12.5px]"><input type="checkbox" name="adequacyChecked" /> {t("adequacy")}</label>
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</ChildSection>
|
||||
|
||||
<ChildSection title={t("contracts")} items={supplier.contracts.map((c) => ({ id: c.id, label: `${c.reference ?? c.type}${c.avDpa ? " · AV" : ""}` }))} kind="contract" addLabel={t("addContract")} disc={disc}>
|
||||
<form action={addContract.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="reference" placeholder={t("reference")} />
|
||||
<div className="grid grid-cols-2 gap-1 text-[12px]">
|
||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="avDpa" defaultChecked /> {t("avDpa")}</label>
|
||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="securityClauses" defaultChecked /> {t("securityClauses")}</label>
|
||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="flowdown" /> {t("flowdown")}</label>
|
||||
<label className="flex items-center gap-1.5"><input type="checkbox" name="customerRequirementsPassed" /> {t("customerReq")}</label>
|
||||
</div>
|
||||
<Input name="validTo" type="date" />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</ChildSection>
|
||||
|
||||
<ChildSection title={t("ndas")} items={supplier.ndas.map((n) => ({ id: n.id, label: n.subject ?? n.parties ?? "NDA" }))} kind="nda" addLabel={t("addNda")} disc={disc}>
|
||||
<form action={addNda.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Input name="parties" placeholder={t("parties")} />
|
||||
<Input name="subject" placeholder={t("subject")} />
|
||||
<Input name="validTo" type="date" />
|
||||
<Input name="extensionStatus" placeholder={t("extensionStatus")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</ChildSection>
|
||||
|
||||
<ChildSection title={t("assessments")} items={supplier.assessments.map((a) => ({ id: a.id, label: `${tAType(a.type)}${a.score != null ? ` · ${a.score}/100` : ""}` }))} kind="assessment" addLabel={t("addAssessment")} disc={disc}>
|
||||
<form action={addAssessment.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<select name="type" className={inputCls}>{(["SELF_ASSESSMENT", "QUESTIONNAIRE", "AUDIT"] as const).map((k) => <option key={k} value={k}>{tAType(k)}</option>)}</select>
|
||||
<Input name="score" type="number" min={0} max={100} placeholder={t("score")} />
|
||||
<Input name="nextReview" type="date" placeholder={t("nextReview")} />
|
||||
<Input name="result" placeholder={t("result")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</ChildSection>
|
||||
|
||||
<ChildSection title={t("decision")} items={supplier.decisions.map((d) => ({ id: d.id, label: d.decision }))} kind="decision" addLabel={t("addDecision")} disc={disc}>
|
||||
<form action={addDecision.bind(null, supplier.id)} className="shadow-card absolute right-0 z-10 mt-2 w-80 space-y-2 rounded-xl border bg-card p-3 text-sm">
|
||||
<Textarea name="reasonNoAudit" required rows={2} placeholder={t("reasonNoAudit")} />
|
||||
<Textarea name="decision" required rows={2} placeholder={t("decisionText")} />
|
||||
<Input name="recordRef" placeholder={t("recordRef")} />
|
||||
<Button type="submit" variant="secondary" size="sm">{tc("add")}</Button>
|
||||
</form>
|
||||
</ChildSection>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
async function ChildSection({
|
||||
title, items, kind, addLabel, disc, children,
|
||||
}: {
|
||||
title: string;
|
||||
items: { id: string; label: string }[];
|
||||
kind: "contract" | "nda" | "evidence" | "assessment" | "decision" | "subcontractor";
|
||||
addLabel: string;
|
||||
disc: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const tc = await getTranslations("common");
|
||||
return (
|
||||
<section className="border-t pt-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="font-heading text-[15px] font-semibold">{title}</p>
|
||||
<details className="relative">
|
||||
<summary className={disc}><span className="inline-flex items-center gap-1.5"><Plus className="size-3.5" /> {addLabel}</span></summary>
|
||||
{children}
|
||||
</details>
|
||||
</div>
|
||||
{items.length > 0 && (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{items.map((it) => (
|
||||
<li key={it.id} className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate">{it.label}</span>
|
||||
<form action={deleteChild.bind(null, kind, it.id)}>
|
||||
<button type="submit" title={tc("remove")} className="text-muted-foreground hover:text-destructive"><X className="size-3.5" /></button>
|
||||
</form>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import Link from "next/link";
|
||||
import { Pencil, Trash2, Check, X, Paperclip, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { updateTask, deleteTask, createOwnTask, approveTask, rejectTask, commentTask, createEvidence, linkEvidence, unlinkEvidence } from "@/server/actions/tasks";
|
||||
import { TASK_TYPE_LABELS, EVIDENCE_KINDS, EVIDENCE_KIND_LABELS, type TaskType, type TaskLinks, type EvidenceKind } from "@/lib/tasks";
|
||||
|
||||
/**
|
||||
* Aufgaben-Popups (Detail/Bearbeiten/Anlegen) — bewusst analog zu den Maßnahmen-Modals
|
||||
* (measure-modals.tsx), damit Aufgaben und Maßnahmen dieselbe Bedienung haben. Öffnen/
|
||||
* Schließen über searchParams der Aufgaben-Seite (?detail=/?edit=/?new=task).
|
||||
*/
|
||||
|
||||
export interface TaskComment {
|
||||
id: string;
|
||||
authorId: string | null;
|
||||
kind: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface EvidenceItem {
|
||||
id: string;
|
||||
title: string;
|
||||
kind: string;
|
||||
fileRef: string | null;
|
||||
control: string | null;
|
||||
validFrom: Date | null;
|
||||
validUntil: Date | null;
|
||||
}
|
||||
|
||||
export interface TaskDetail {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
priority: string;
|
||||
dueDate: Date | null;
|
||||
assigneeId: string | null;
|
||||
createdById: string | null;
|
||||
links: unknown;
|
||||
updatedAt: Date;
|
||||
comments?: TaskComment[];
|
||||
evidence?: EvidenceItem[];
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = { PROPOSED: "Vorschlag", OPEN: "Offen", IN_PROGRESS: "In Umsetzung", DONE: "Erledigt", REJECTED: "Abgelehnt", CANCELLED: "Abgebrochen", DISCARDED: "Verworfen" };
|
||||
const STATUS_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = { PROPOSED: "warn", OPEN: "info", IN_PROGRESS: "warn", DONE: "ok", REJECTED: "warn", CANCELLED: "mut", DISCARDED: "mut" };
|
||||
const KIND_LABEL: Record<string, string> = { submit: "übernommen", approve: "freigegeben", reject: "abgelehnt", comment: "Kommentar" };
|
||||
const PRIO_LABEL: Record<string, string> = { hoch: "Hoch", mittel: "Mittel", niedrig: "Niedrig" };
|
||||
const PRIO_TONE: Record<string, "risk" | "warn" | "mut"> = { hoch: "risk", mittel: "warn", niedrig: "mut" };
|
||||
const EDIT_STATUSES = ["OPEN", "IN_PROGRESS", "DONE", "CANCELLED"] as const;
|
||||
const PRIORITIES = ["hoch", "mittel", "niedrig"] as const;
|
||||
// Manuell anlegbare Aufgabentypen (Review-/Freigabe-Typen entstehen aus den Fachmodulen).
|
||||
const CREATABLE_TYPES: TaskType[] = ["organizational", "technical", "document_create", "evidence_provide"];
|
||||
|
||||
function typeLabel(type: string): string {
|
||||
return TASK_TYPE_LABELS[type as TaskType] ?? type;
|
||||
}
|
||||
function linkRef(links: unknown): string | null {
|
||||
const l = (links ?? {}) as TaskLinks;
|
||||
const parts = [l.control && `Control ${l.control}`, l.document, l.risk && `Risiko ${l.risk}`, l.asset && `Asset ${l.asset}`].filter(Boolean);
|
||||
return parts.length ? parts.join(" · ") : null;
|
||||
}
|
||||
function controlFromLinks(links: unknown): string | null {
|
||||
return ((links ?? {}) as TaskLinks).control ?? null;
|
||||
}
|
||||
|
||||
const selectClass = "h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm";
|
||||
|
||||
const EVIDENCE_KIND_TONE: Record<string, "ok" | "info" | "warn" | "mut"> = {
|
||||
record: "info", protocol: "info", screenshot: "mut", export: "mut",
|
||||
};
|
||||
|
||||
/**
|
||||
* Nachweis-/Evidence-Register einer Aufgabe (M3, 1.6). Zeigt verknüpfte Nachweise
|
||||
* (Art + Wirksamkeit mit Ablauf-Markierung), erlaubt Anlegen/Verknüpfen/Entfernen.
|
||||
* Der Nachweis ist die Voraussetzung für „audit-ready" (Wirksamkeit, 2.2).
|
||||
*/
|
||||
function EvidenceSection({
|
||||
taskId,
|
||||
evidence,
|
||||
linkable,
|
||||
defaultControl,
|
||||
canManage,
|
||||
}: {
|
||||
taskId: string;
|
||||
evidence: EvidenceItem[];
|
||||
linkable: { id: string; title: string; kind: string }[];
|
||||
defaultControl: string | null;
|
||||
canManage: boolean;
|
||||
}) {
|
||||
const now = new Date();
|
||||
return (
|
||||
<div className="rounded-xl border bg-[var(--surface-soft)] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="size-4 text-[var(--primary)]" />
|
||||
<b className="text-[13px]">Nachweise</b>
|
||||
<span className="text-[11px] text-muted-foreground">Voraussetzung für „audit-ready“ (Wirksamkeit)</span>
|
||||
</div>
|
||||
|
||||
{evidence.length === 0 ? (
|
||||
<p className="mt-2 text-[12px] text-muted-foreground">Noch kein Nachweis verknüpft.</p>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{evidence.map((e) => {
|
||||
const expired = !!e.validUntil && e.validUntil < now;
|
||||
return (
|
||||
<li key={e.id} className="flex flex-wrap items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-[12px]">
|
||||
<Pill tone={EVIDENCE_KIND_TONE[e.kind] ?? "mut"}>{EVIDENCE_KIND_LABELS[e.kind as EvidenceKind] ?? e.kind}</Pill>
|
||||
{e.fileRef ? (
|
||||
<a href={e.fileRef} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 font-medium hover:underline">
|
||||
<Paperclip className="size-3" /> {e.title}
|
||||
</a>
|
||||
) : (
|
||||
<span className="font-medium">{e.title}</span>
|
||||
)}
|
||||
{e.control && <span className="text-muted-foreground">Control {e.control}</span>}
|
||||
{e.validUntil && (
|
||||
expired
|
||||
? <Pill tone="risk">abgelaufen {e.validUntil.toLocaleDateString("de-DE")}</Pill>
|
||||
: <span className="text-muted-foreground">gültig bis {e.validUntil.toLocaleDateString("de-DE")}</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<form action={unlinkEvidence.bind(null, e.id)} className="ml-auto">
|
||||
<Button type="submit" variant="ghost" size="sm" title="Nachweis entfernen"><X className="size-3.5" /></Button>
|
||||
</form>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canManage && (
|
||||
<>
|
||||
<form action={createEvidence.bind(null, taskId)} className="mt-3 space-y-2 border-t pt-3">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Input name="title" placeholder="Titel des Nachweises" required className="h-8" />
|
||||
<select name="kind" defaultValue="record" className={`${selectClass} h-8`}>
|
||||
{EVIDENCE_KINDS.map((k) => <option key={k} value={k}>{EVIDENCE_KIND_LABELS[k]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Input name="fileRef" placeholder="Verweis/URL zur Ablage (optional)" className="h-8" />
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
<Input name="control" defaultValue={defaultControl ?? ""} placeholder="Control (optional)" className="h-8" />
|
||||
<label className="text-[11px] text-muted-foreground">Gültig ab
|
||||
<Input type="date" name="validFrom" className="mt-0.5 h-8" />
|
||||
</label>
|
||||
<label className="text-[11px] text-muted-foreground">Gültig bis
|
||||
<Input type="date" name="validUntil" className="mt-0.5 h-8" />
|
||||
</label>
|
||||
</div>
|
||||
<Button type="submit" size="sm"><Paperclip className="size-4" /> Nachweis anlegen & verknüpfen</Button>
|
||||
</form>
|
||||
|
||||
{linkable.length > 0 && (
|
||||
<form action={linkEvidence.bind(null, taskId)} className="mt-2 flex gap-2">
|
||||
<select name="evidenceId" className={`${selectClass} h-8`}>
|
||||
{linkable.map((e) => (
|
||||
<option key={e.id} value={e.id}>{(EVIDENCE_KIND_LABELS[e.kind as EvidenceKind] ?? e.kind)} · {e.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button type="submit" variant="outline" size="sm">Bestehenden verknüpfen</Button>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aufgaben-Detail als Popup: Fakten, Kommentare, Freigabe/Ablehnung und Bearbeiten. */
|
||||
export function TaskDetailModal({
|
||||
task,
|
||||
me,
|
||||
ownerName,
|
||||
name,
|
||||
canManage,
|
||||
linkableEvidence = [],
|
||||
}: {
|
||||
task: TaskDetail;
|
||||
me: string;
|
||||
ownerName: string;
|
||||
name: (id: string | null) => string;
|
||||
canManage: boolean;
|
||||
linkableEvidence?: { id: string; title: string; kind: string }[];
|
||||
}) {
|
||||
const ref = linkRef(task.links);
|
||||
const comments = task.comments ?? [];
|
||||
const isAssignee = task.assigneeId === me;
|
||||
const isParticipant = isAssignee || task.createdById === me;
|
||||
const isApproval = task.type === "policy_approval" && task.status === "OPEN";
|
||||
return (
|
||||
<Modal
|
||||
title={task.title}
|
||||
sub={typeLabel(task.type)}
|
||||
headerExtra={
|
||||
<span className="flex items-center gap-2">
|
||||
<Pill tone={PRIO_TONE[task.priority] ?? "mut"}>{PRIO_LABEL[task.priority] ?? task.priority}</Pill>
|
||||
<Pill tone={STATUS_TONE[task.status] ?? "mut"}>{STATUS_LABEL[task.status] ?? task.status}</Pill>
|
||||
</span>
|
||||
}
|
||||
closeHref="/tasks"
|
||||
closeLabel="Schließen"
|
||||
footer={
|
||||
<>
|
||||
{canManage && (
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/tasks?edit=${task.id}`} />}>
|
||||
<Pencil className="size-4" /> Bearbeiten
|
||||
</Button>
|
||||
)}
|
||||
<Button nativeButton={false} render={<Link href="/tasks" />}>Schließen</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<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">
|
||||
<b>{task.title}</b>
|
||||
{task.description && <p className="mt-1.5 text-[12.5px] leading-relaxed whitespace-pre-line">{task.description}</p>}
|
||||
<dl className="mt-3 grid grid-cols-[7.5rem_1fr] gap-1.5 text-[12.5px]">
|
||||
<dt className="text-muted-foreground">Owner</dt>
|
||||
<dd>{ownerName}</dd>
|
||||
<dt className="text-muted-foreground">Fällig</dt>
|
||||
<dd>{task.dueDate ? task.dueDate.toLocaleDateString("de-DE") : "—"}</dd>
|
||||
{ref && <><dt className="text-muted-foreground">Bezug</dt><dd>{ref}</dd></>}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Freigabe/Ablehnung nur für Freigabe-Aufgaben durch den zugewiesenen Freigeber. */}
|
||||
{isApproval && isAssignee && (
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<form action={approveTask.bind(null, task.id)} className="flex gap-2">
|
||||
<Input name="note" placeholder="Kommentar (optional)" className="h-8" />
|
||||
<Button type="submit" size="sm"><Check className="size-4" /> Freigeben</Button>
|
||||
</form>
|
||||
<form action={rejectTask.bind(null, task.id)} className="flex gap-2">
|
||||
<Input name="note" placeholder="Grund (erforderlich)" required className="h-8" />
|
||||
<Button type="submit" variant="outline" size="sm"><X className="size-4" /> Ablehnen</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{isApproval && !isAssignee && (
|
||||
<p className="text-[11.5px] text-muted-foreground">Wartet auf Freigabe durch {name(task.assigneeId)}.</p>
|
||||
)}
|
||||
|
||||
<EvidenceSection
|
||||
taskId={task.id}
|
||||
evidence={task.evidence ?? []}
|
||||
linkable={linkableEvidence}
|
||||
defaultControl={controlFromLinks(task.links)}
|
||||
canManage={canManage}
|
||||
/>
|
||||
|
||||
{comments.length > 0 && (
|
||||
<ul className="space-y-1.5 border-t pt-3">
|
||||
{comments.map((c) => (
|
||||
<li key={c.id} className="text-[12.5px]">
|
||||
<span className="text-muted-foreground">{name(c.authorId)} · {KIND_LABEL[c.kind] ?? c.kind}:</span> {c.body}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{isParticipant && (
|
||||
<form action={commentTask.bind(null, task.id)} className="flex gap-2">
|
||||
<Input name="body" placeholder="Kommentar hinzufügen…" className="h-8" />
|
||||
<Button type="submit" variant="ghost" size="sm">Kommentieren</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Formularfelder für Anlegen/Bearbeiten. */
|
||||
function TaskFields({ task, users, withStatus }: { task?: TaskDetail; users: { id: string; name: string | null }[]; withStatus?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="title">Titel</Label>
|
||||
<Input id="title" name="title" required defaultValue={task?.title} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="description">Beschreibung</Label>
|
||||
<Textarea id="description" name="description" rows={3} defaultValue={task?.description ?? ""} className="mt-1" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{withStatus && (
|
||||
<div>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<select id="status" name="status" defaultValue={task?.status ?? "OPEN"} className={`${selectClass} mt-1`}>
|
||||
{EDIT_STATUSES.map((v) => <option key={v} value={v}>{STATUS_LABEL[v]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="priority">Priorität</Label>
|
||||
<select id="priority" name="priority" defaultValue={task?.priority ?? "mittel"} className={`${selectClass} mt-1`}>
|
||||
{PRIORITIES.map((v) => <option key={v} value={v}>{PRIO_LABEL[v]}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="owner">Owner</Label>
|
||||
<select id="owner" name="owner" defaultValue={task?.assigneeId ?? ""} className={`${selectClass} mt-1`}>
|
||||
<option value="">— nicht zugewiesen —</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.name ?? u.id}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="dueDate">Fällig bis</Label>
|
||||
<Input id="dueDate" name="dueDate" type="date" defaultValue={task?.dueDate ? task.dueDate.toISOString().slice(0, 10) : ""} className="mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Aufgabe bearbeiten als Popup (inkl. Löschen). */
|
||||
export function TaskEditModal({ task, users }: { task: TaskDetail; users: { id: string; name: string | null }[] }) {
|
||||
return (
|
||||
<Modal title="Aufgabe bearbeiten" sub={task.title} closeHref={`/tasks?detail=${task.id}`} closeLabel="Schließen">
|
||||
<div className="p-5">
|
||||
<form key={task.updatedAt.toISOString()} action={updateTask.bind(null, task.id)} className="space-y-4">
|
||||
<TaskFields task={task} users={users} withStatus />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit">Speichern</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={`/tasks?detail=${task.id}`} />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="mt-5 border-t pt-4">
|
||||
<form action={deleteTask.bind(null, task.id)}>
|
||||
<Button type="submit" variant="destructive" size="sm"><Trash2 className="size-4" /> Löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Neue Aufgabe anlegen als Popup. */
|
||||
export function TaskCreateModal({ users }: { users: { id: string; name: string | null }[] }) {
|
||||
return (
|
||||
<Modal title="Neue Aufgabe" sub="Für Maßnahmen (z. B. zu Risiken) nutzen Sie „Neue Maßnahme“." closeHref="/tasks" closeLabel="Schließen">
|
||||
<form action={createOwnTask} className="space-y-4 p-5">
|
||||
<div>
|
||||
<Label htmlFor="type">Art</Label>
|
||||
<select id="type" name="type" defaultValue="organizational" className={`${selectClass} mt-1`}>
|
||||
{CREATABLE_TYPES.map((v) => <option key={v} value={v}>{typeLabel(v)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<TaskFields users={users} />
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button type="submit">Speichern</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href="/tasks" />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { confirmOwnMfaEnrollment, type MfaEnrollState } from "@/server/actions/account";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
const initial: MfaEnrollState = { status: "idle" };
|
||||
|
||||
/** MFA-Einrichtung für den angemeldeten Mandanten-Nutzer (QR wird serverseitig erzeugt). */
|
||||
export function TenantMfaEnrollForm() {
|
||||
const [state, action, pending] = useActionState(confirmOwnMfaEnrollment, initial);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg bg-[rgba(46,204,113,0.14)] px-3 py-2 text-sm text-[var(--ok)]">
|
||||
MFA ist aktiv. Bewahren Sie diese Recovery-Codes sicher auf — sie werden <strong>nur jetzt</strong> angezeigt.
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{state.recoveryCodes.map((c) => (
|
||||
<li key={c} className="rounded border bg-card px-3 py-1.5 text-center tracking-wider">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="mfa-token">6-stelliger Code aus der Authenticator-App</Label>
|
||||
<Input id="mfa-token" name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" required className="mt-1" />
|
||||
</div>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)]">{state.message}</p>
|
||||
)}
|
||||
<Button type="submit" size="sm" disabled={pending}>{pending ? "Prüfe…" : "MFA aktivieren"}</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useActionState } from "react";
|
||||
import { regenerateOwnRecoveryCodes, type MfaEnrollState } from "@/server/actions/account";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const initial: MfaEnrollState = { status: "idle" };
|
||||
|
||||
/** SEC3-c: Recovery-Codes neu erzeugen — Step-up per aktuellem TOTP-Code (F-08). */
|
||||
export function TenantRecoveryRegenForm() {
|
||||
const [state, action, pending] = useActionState(regenerateOwnRecoveryCodes, initial);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12px] text-[var(--ok)]">Neue Recovery-Codes — nur jetzt sichtbar, die alten sind ungültig:</p>
|
||||
<ul className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{state.recoveryCodes.map((c) => (
|
||||
<li key={c} className="rounded border bg-card px-3 py-1.5 text-center tracking-wider">{c}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-[12.5px] text-muted-foreground">Recovery-Codes neu erzeugen</summary>
|
||||
<form action={action} className="mt-2 flex items-center gap-2">
|
||||
<Input name="token" inputMode="numeric" autoComplete="one-time-code" placeholder="6-stelliger Code" className="h-8 w-36" required />
|
||||
<Button type="submit" variant="outline" size="sm" disabled={pending}>{pending ? "Prüfe…" : "Neu erzeugen"}</Button>
|
||||
</form>
|
||||
{state.status === "error" && <p role="alert" className="mt-2 text-[12px] text-[var(--risk)]">{state.message}</p>}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { setActiveTenant } from "@/server/actions/tenant-switch";
|
||||
import type { SessionMembership } from "@/types/next-auth";
|
||||
|
||||
/**
|
||||
* WS2 (Option C) — Mandantenwechsel in der Sidebar. Nur sichtbar, wenn die Person
|
||||
* mehreren Mandanten angehört. Jede Auswahl läuft server-autoritativ über
|
||||
* setActiveTenant (prägt die Session neu, löst Rechte neu auf).
|
||||
*/
|
||||
export function TenantSwitcher({
|
||||
memberships,
|
||||
activeSlug,
|
||||
}: {
|
||||
memberships: SessionMembership[];
|
||||
activeSlug: string;
|
||||
}) {
|
||||
if (memberships.length <= 1) return null;
|
||||
const others = memberships.filter((m) => m.tenantSlug !== activeSlug);
|
||||
|
||||
return (
|
||||
<details className="group mt-2">
|
||||
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-2 py-1 text-[11.5px] text-muted-foreground hover:bg-sidebar-accent">
|
||||
<span className="truncate">Mandant wechseln</span>
|
||||
<span className="text-[10px] opacity-70 group-open:rotate-180">▾</span>
|
||||
</summary>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{others.map((m) => (
|
||||
<form key={m.membershipId} action={setActiveTenant.bind(null, m.membershipId)}>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full truncate rounded-md px-2 py-1 text-left text-[12px] hover:bg-sidebar-accent"
|
||||
title={`Zu ${m.tenantName} wechseln`}
|
||||
>
|
||||
{m.tenantName}
|
||||
</button>
|
||||
</form>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { setUiLocale } from "@/server/actions/account";
|
||||
|
||||
/**
|
||||
* Umschalter der persönlichen UI-Sprache (Option C: `Identity.uiLocale`). Server-
|
||||
* gerendert über die `setUiLocale`-Action; die aktive Sprache ist hervorgehoben.
|
||||
* Kein Client-State — die Action speichert und revalidiert das komplette Layout.
|
||||
*/
|
||||
export async function UiLocaleSwitcher({ current }: { current: string }) {
|
||||
const t = await getTranslations("common");
|
||||
const active = current === "en" ? "en" : "de";
|
||||
const options: { code: "de" | "en"; label: string }[] = [
|
||||
{ code: "de", label: "DE" },
|
||||
{ code: "en", label: "EN" },
|
||||
];
|
||||
return (
|
||||
<div
|
||||
className="flex items-center overflow-hidden rounded-lg border text-[12px] font-semibold"
|
||||
role="group"
|
||||
aria-label={t("language")}
|
||||
title={t("language")}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const isActive = o.code === active;
|
||||
return (
|
||||
<form key={o.code} action={setUiLocale.bind(null, o.code)}>
|
||||
<button
|
||||
type="submit"
|
||||
aria-pressed={isActive}
|
||||
disabled={isActive}
|
||||
className={
|
||||
isActive
|
||||
? "bg-grad-soft px-2.5 py-1 text-white"
|
||||
: "px-2.5 py-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding font-heading text-sm font-semibold whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
// Primär-Buttons mit dem Certvia-Verlauf (--grad-primary)
|
||||
default: "bg-grad-soft text-white hover:opacity-90",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl bg-card py-(--card-spacing) text-sm text-card-foreground shadow-card ring-1 ring-border [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-(--card-spacing)", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-[var(--surface-soft)] has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-3 text-left align-middle text-[11.5px] font-bold tracking-[.04em] uppercase whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
/**
|
||||
* Generische Benutzer-Formulare für Popups (Anlegen/Bearbeiten). Die konkreten
|
||||
* Server-Actions werden als (bereits gebundene) Props übergeben — dieselben
|
||||
* Komponenten dienen der Plattform-Admin- und der Mandanten-Admin-Verwaltung.
|
||||
* Die Zustandstypen sind in beiden Action-Dateien strukturgleich.
|
||||
*/
|
||||
|
||||
export type CreateResult =
|
||||
| { status: "idle" }
|
||||
| { status: "error"; message: string; values?: { name: string; email: string; roleIds: string[] } }
|
||||
| { status: "done"; email: string; generatedPassword: string | null; invited: boolean };
|
||||
export type EditResult = { status: "idle" } | { status: "error"; message: string } | { status: "ok" };
|
||||
|
||||
export interface RoleOption { id: string; name: string }
|
||||
|
||||
type CreateAction = (prev: CreateResult, formData: FormData) => Promise<CreateResult>;
|
||||
type EditAction = (prev: EditResult, formData: FormData) => Promise<EditResult>;
|
||||
type PlainAction = (formData: FormData) => void | Promise<void>;
|
||||
|
||||
function RoleChecks({ roles, selected, idPrefix }: { roles: RoleOption[]; selected: Set<string>; idPrefix: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1.5">
|
||||
{roles.map((r) => (
|
||||
<label key={r.id} htmlFor={`${idPrefix}-${r.id}`} className="flex items-center gap-1.5 text-[13px]">
|
||||
<input id={`${idPrefix}-${r.id}`} type="checkbox" name="roles" value={r.id} defaultChecked={selected.has(r.id)} />
|
||||
{r.name}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserCreateForm({ action, roles, closeHref }: { action: CreateAction; roles: RoleOption[]; closeHref: string }) {
|
||||
const [state, formAction, pending] = useActionState(action, { status: "idle" } as CreateResult);
|
||||
|
||||
if (state.status === "done") {
|
||||
return (
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-sm text-[var(--ok)]">Nutzer {state.email} angelegt.</p>
|
||||
{/* Option C (WS3): Anlage nur per Einladung; neutrale Rückmeldung (kein
|
||||
Cross-Tenant-Leak, ob die Person schon ein Konto hatte). */}
|
||||
<p className="text-[12.5px] text-muted-foreground">Einladung an {state.email} gesendet. Die Person richtet ihr Konto selbst über den Link ein bzw. sieht den Mandanten künftig in ihrer Auswahl.</p>
|
||||
<Button nativeButton={false} render={<Link href={closeHref} />}>Fertig</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Bei Fehler die zuvor eingegebenen Werte zurückspielen (React setzt Felder nach
|
||||
// einer Action zurück → ohne defaultValue wäre das Formular leer).
|
||||
const errVals = state.status === "error" ? state.values : undefined;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="grid gap-3 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="cu-name">Name *</Label>
|
||||
<Input id="cu-name" name="name" required className="mt-1" placeholder="Vor- und Nachname" defaultValue={errVals?.name} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="cu-email">E-Mail *</Label>
|
||||
<Input id="cu-email" name="email" type="email" required className="mt-1" placeholder="person@firma.example" defaultValue={errVals?.email} />
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Rollen *</Label>
|
||||
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(errVals?.roleIds ?? [])} idPrefix="cu" /></div>
|
||||
</div>
|
||||
<p className="text-[12px] text-muted-foreground md:col-span-2">
|
||||
Die Person erhält eine Einladung und setzt ihr Passwort selbst (Option C). Ein Initialpasswort wird nicht mehr vergeben.
|
||||
</p>
|
||||
{state.status === "error" && (
|
||||
<p role="alert" className="rounded-lg bg-[rgba(255,107,107,0.16)] px-3 py-2 text-sm text-[var(--risk)] md:col-span-2">{state.message}</p>
|
||||
)}
|
||||
<div className="flex gap-2 md:col-span-2">
|
||||
<Button type="submit" disabled={pending}>{pending ? "Lege an…" : "Benutzer anlegen"}</Button>
|
||||
<Button variant="outline" nativeButton={false} render={<Link href={closeHref} />}>Abbrechen</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserEditForm({
|
||||
user, roles, updateAction, rolesAction, statusAction,
|
||||
}: {
|
||||
user: { id: string; name: string; email: string; status: string; roleIds: string[]; isSelf?: boolean };
|
||||
roles: RoleOption[];
|
||||
updateAction: EditAction;
|
||||
rolesAction: PlainAction;
|
||||
statusAction: PlainAction;
|
||||
}) {
|
||||
const [editState, editFormAction, editPending] = useActionState(updateAction, { status: "idle" } as EditResult);
|
||||
const active = user.status === "ACTIVE";
|
||||
|
||||
return (
|
||||
<div className="space-y-5 p-5">
|
||||
<form action={editFormAction} className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="eu-name">Name {user.isSelf && <span className="text-[11px] text-muted-foreground">(Sie)</span>}</Label>
|
||||
<Input id="eu-name" name="name" defaultValue={user.name} className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="eu-email">E-Mail</Label>
|
||||
<Input id="eu-email" name="email" type="email" defaultValue={user.email} className="mt-1" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 sm:col-span-2">
|
||||
<Button type="submit" size="sm" disabled={editPending}>{editPending ? "…" : "Stammdaten speichern"}</Button>
|
||||
{editState.status === "ok" && <span className="text-[12px] text-[var(--ok)]">Gespeichert</span>}
|
||||
{editState.status === "error" && <span className="text-[12px] text-[var(--risk)]">{editState.message}</span>}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form action={rolesAction} className="border-t pt-4">
|
||||
<Label className="text-[12px] text-muted-foreground">Rollen</Label>
|
||||
<div className="mt-1"><RoleChecks roles={roles} selected={new Set(user.roleIds)} idPrefix="eu" /></div>
|
||||
<Button type="submit" variant="outline" size="sm" className="mt-2">Rollen speichern</Button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
||||
<form action={statusAction}><Button type="submit" variant="outline" size="sm">{active ? "Deaktivieren" : "Reaktivieren"}</Button></form>
|
||||
</div>
|
||||
{/* Option C (WS4): Passwort/MFA gehören der globalen Identity — kein Mandanten-
|
||||
Admin-Reset mehr. Zurücksetzen erfolgt per Self-Service (Recovery-Codes) bzw.
|
||||
über die Plattform-Ebene. */}
|
||||
<p className="text-[12px] text-muted-foreground">
|
||||
Passwort und MFA verwaltet die Person selbst (Anmeldung → „Passwort vergessen“) — ein Zurücksetzen durch die Administration ist nicht mehr vorgesehen.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
|
||||
export interface UserTableRow {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
roleNames: string[];
|
||||
isSelf?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reine Anzeige-Tabelle der Benutzer. Zeilen verlinken auf das Bearbeiten-Popup
|
||||
* (?edit=<id>), der Button auf das Anlegen-Popup (?new=1). Keine Inline-Bearbeitung.
|
||||
*/
|
||||
export function UserTable({
|
||||
users, newHref, editHref,
|
||||
}: {
|
||||
users: UserTableRow[];
|
||||
newHref: string;
|
||||
editHref: (id: string) => string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<Button nativeButton={false} render={<Link href={newHref} />}><Plus className="size-4" /> Neuer Benutzer</Button>
|
||||
</div>
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>E-Mail</TableHead>
|
||||
<TableHead>Rollen</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id} className="cursor-pointer hover:bg-muted/40">
|
||||
<TableCell className="p-0">
|
||||
<Link href={editHref(u.id)} className="block px-4 py-3 font-medium">
|
||||
{u.name}{u.isSelf && <span className="text-[11px] text-muted-foreground"> (Sie)</span>}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="p-0"><Link href={editHref(u.id)} className="block px-4 py-3 text-muted-foreground">{u.email}</Link></TableCell>
|
||||
<TableCell className="p-0"><Link href={editHref(u.id)} className="block px-4 py-3 text-[12.5px]">{u.roleNames.join(", ") || "—"}</Link></TableCell>
|
||||
<TableCell><Pill tone={u.status === "ACTIVE" ? "ok" : "mut"}>{u.status === "ACTIVE" ? "Aktiv" : "Deaktiviert"}</Pill></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && (
|
||||
<TableRow><TableCell colSpan={4} className="text-muted-foreground">Noch keine Benutzer.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user